From 50c3bdf0e3e2d2b17a1ddbd1183f1385378dbf84 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 9 Jun 2026 23:44:16 -0700 Subject: [PATCH 1/9] feat(ui): add scheduled task persistence to ChatDatabase --- src/gaia/ui/database.py | 123 ++++++++++++++++++++++++++++ tests/unit/chat/ui/test_database.py | 75 +++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/src/gaia/ui/database.py b/src/gaia/ui/database.py index fa6581551..e01f03299 100644 --- a/src/gaia/ui/database.py +++ b/src/gaia/ui/database.py @@ -75,10 +75,36 @@ value TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS scheduled_tasks ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + interval_seconds INTEGER NOT NULL, + prompt TEXT NOT NULL, + status TEXT DEFAULT 'active', + created_at TEXT, + last_run_at TEXT, + next_run_at TEXT, + last_result TEXT, + run_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + session_id TEXT, + schedule_config TEXT +); + +CREATE TABLE IF NOT EXISTS schedule_results ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE CASCADE, + executed_at TEXT NOT NULL, + result TEXT, + error TEXT +); + -- Indexes CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(file_hash); CREATE INDEX IF NOT EXISTS idx_session_docs ON session_documents(session_id); +CREATE INDEX IF NOT EXISTS idx_schedule_results_task + ON schedule_results(task_id, executed_at DESC); """ @@ -880,6 +906,103 @@ def get_all_settings(self) -> Dict[str, str]: rows = self._conn.execute("SELECT key, value FROM settings").fetchall() return {row["key"]: row["value"] for row in rows} + # ── Scheduled tasks ───────────────────────────────────────────────── + + def create_scheduled_task(self, row: Dict[str, Any]) -> None: + """Insert a scheduled task row. + + Args: + row: Mapping with keys id, name, interval_seconds, prompt, status, + created_at, next_run_at, run_count, error_count, session_id, + schedule_config. + + Raises: + sqlite3.IntegrityError: if a task with the same name exists. + """ + with self._transaction(): + self._conn.execute( + """INSERT INTO scheduled_tasks + (id, name, interval_seconds, prompt, status, created_at, + next_run_at, run_count, error_count, session_id, schedule_config) + VALUES (:id, :name, :interval_seconds, :prompt, :status, + :created_at, :next_run_at, :run_count, :error_count, + :session_id, :schedule_config)""", + row, + ) + + def update_scheduled_task( + self, + task_id: str, + *, + status: str, + last_run_at: str | None = None, + next_run_at: str | None = None, + last_result: str | None = None, + run_count: int = 0, + error_count: int = 0, + session_id: str | None = None, + schedule_config: str | None = None, + ) -> None: + """Update the mutable fields of a scheduled task row.""" + with self._transaction(): + self._conn.execute( + """UPDATE scheduled_tasks + SET status = ?, last_run_at = ?, next_run_at = ?, last_result = ?, + run_count = ?, error_count = ?, session_id = ?, schedule_config = ? + WHERE id = ?""", + ( + status, + last_run_at, + next_run_at, + last_result, + run_count, + error_count, + session_id, + schedule_config, + task_id, + ), + ) + + def delete_scheduled_task(self, task_id: str) -> None: + """Delete a scheduled task and its results.""" + with self._transaction(): + self._conn.execute( + "DELETE FROM schedule_results WHERE task_id = ?", (task_id,) + ) + self._conn.execute("DELETE FROM scheduled_tasks WHERE id = ?", (task_id,)) + + def list_scheduled_tasks(self) -> List[Dict[str, Any]]: + """Load all scheduled task rows.""" + with self._lock: + rows = self._conn.execute("SELECT * FROM scheduled_tasks").fetchall() + return [dict(r) for r in rows] + + def store_schedule_result( + self, + result_id: str, + task_id: str, + executed_at: str, + result: str | None, + error: str | None, + ) -> None: + """Store one schedule execution result.""" + with self._transaction(): + self._conn.execute( + """INSERT INTO schedule_results (id, task_id, executed_at, result, error) + VALUES (?, ?, ?, ?, ?)""", + (result_id, task_id, executed_at, result, error), + ) + + def get_schedule_results(self, task_id: str, limit: int = 20) -> List[Dict[str, Any]]: + """Get past execution results for a task, newest first.""" + with self._lock: + rows = self._conn.execute( + """SELECT * FROM schedule_results WHERE task_id = ? + ORDER BY executed_at DESC LIMIT ?""", + (task_id, limit), + ).fetchall() + return [dict(r) for r in rows] + # ── Stats ─────────────────────────────────────────────────────────── def get_stats(self) -> Dict[str, Any]: diff --git a/tests/unit/chat/ui/test_database.py b/tests/unit/chat/ui/test_database.py index 3b3fa0c3c..bcb839774 100644 --- a/tests/unit/chat/ui/test_database.py +++ b/tests/unit/chat/ui/test_database.py @@ -586,3 +586,78 @@ def test_get_stats(self, db): assert stats["documents"] == 1 assert stats["total_chunks"] == 10 assert stats["total_size_bytes"] == 1024 + + +class TestScheduledTaskStorage: + """Schedule persistence via public ChatDatabase API (PR #517 salvage).""" + + def _task_row(self, **overrides): + row = { + "id": "t-1", + "name": "morning-brief", + "interval_seconds": 3600, + "prompt": "Summarize my inbox", + "status": "active", + "created_at": "2026-06-09T00:00:00+00:00", + "next_run_at": None, + "run_count": 0, + "error_count": 0, + "session_id": None, + "schedule_config": None, + } + row.update(overrides) + return row + + def test_create_and_list_scheduled_tasks(self, db): + db.create_scheduled_task(self._task_row()) + rows = db.list_scheduled_tasks() + assert len(rows) == 1 + assert rows[0]["name"] == "morning-brief" + assert rows[0]["interval_seconds"] == 3600 + + def test_duplicate_name_raises(self, db): + db.create_scheduled_task(self._task_row()) + with pytest.raises(sqlite3.IntegrityError): + db.create_scheduled_task(self._task_row(id="t-2")) + + def test_update_scheduled_task(self, db): + db.create_scheduled_task(self._task_row()) + db.update_scheduled_task( + "t-1", + status="paused", + run_count=3, + last_run_at="2026-06-09T01:00:00+00:00", + next_run_at=None, + last_result="ok", + error_count=0, + session_id="s-9", + schedule_config=None, + ) + rows = db.list_scheduled_tasks() + assert rows[0]["status"] == "paused" + assert rows[0]["run_count"] == 3 + assert rows[0]["session_id"] == "s-9" + + def test_delete_scheduled_task_cascades_results(self, db): + db.create_scheduled_task(self._task_row()) + db.store_schedule_result( + "r-1", "t-1", "2026-06-09T01:00:00+00:00", result="done", error=None + ) + db.delete_scheduled_task("t-1") + assert db.list_scheduled_tasks() == [] + assert db.get_schedule_results("t-1") == [] + + def test_store_and_get_results_ordered_desc(self, db): + db.create_scheduled_task(self._task_row()) + db.store_schedule_result("r-1", "t-1", "2026-06-09T01:00:00+00:00", "first", None) + db.store_schedule_result("r-2", "t-1", "2026-06-09T02:00:00+00:00", "second", None) + results = db.get_schedule_results("t-1", limit=10) + assert [r["result"] for r in results] == ["second", "first"] + + def test_get_results_respects_limit(self, db): + db.create_scheduled_task(self._task_row()) + for i in range(5): + db.store_schedule_result( + f"r-{i}", "t-1", f"2026-06-09T0{i}:00:00+00:00", str(i), None + ) + assert len(db.get_schedule_results("t-1", limit=2)) == 2 From 04e58c502b0c5d656c30207c6f9977fb76fc5447 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 9 Jun 2026 23:48:06 -0700 Subject: [PATCH 2/9] feat(ui): port task scheduler engine with fail-loudly hardening Ported from kalin/autonomous-agent-infra (#517) with fixes: - ScheduleConfig.from_json raises on malformed/unknown-key JSON instead of silently returning a config that never fires - Scheduler requires a callable executor (dry-run no-op path removed) - _load_tasks propagates DB errors instead of booting with zero tasks; corrupt schedule_config rows fail the boot loudly - All SQL moved behind public ChatDatabase methods (no _lock/_conn use) - Concurrency semaphore (default 1) bounds simultaneous task runs - Session-write failures log at ERROR with tracebacks --- src/gaia/ui/scheduler.py | 1231 ++++++++++++++++++++++++++++++++++ tests/unit/test_scheduler.py | 485 ++++++++++++++ 2 files changed, 1716 insertions(+) create mode 100644 src/gaia/ui/scheduler.py create mode 100644 tests/unit/test_scheduler.py diff --git a/src/gaia/ui/scheduler.py b/src/gaia/ui/scheduler.py new file mode 100644 index 000000000..b010472fc --- /dev/null +++ b/src/gaia/ui/scheduler.py @@ -0,0 +1,1231 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Async task scheduler for GAIA Agent UI. + +Manages recurring scheduled tasks with asyncio timers. Tasks are persisted +in the ChatDatabase and automatically restarted on server startup. + +Supports interval strings like "every 6h", "every 30m", "every 24h", +"daily at 9am", "every monday at 3pm", "every hour from 8am to 6pm", etc. +""" + +import asyncio +import json +import logging +import re +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# ── Day name mappings ──────────────────────────────────────────────────────── + +_DAY_FULL = { + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6, +} +_DAY_ABBR = { + "mon": 0, + "tue": 1, + "wed": 2, + "thu": 3, + "fri": 4, + "sat": 5, + "sun": 6, +} +_ALL_DAY_NAMES = {**_DAY_FULL, **_DAY_ABBR} + + +def parse_interval(interval_str: str) -> int: + """Parse a human-readable interval string into seconds. + + Supported formats: + - "every 30m" or "every 30 minutes" + - "every 6h" or "every 6 hours" + - "every 2d" or "every 2 days" + - "every 30s" or "every 30 seconds" + - "every 2w" or "every 2 weeks" + - "every monday", "every friday", etc. (weekly on that day) + - "daily" (alias for every 24h) + - "hourly" (alias for every 1h) + - "weekly" (alias for every 7d) + + Args: + interval_str: Human-readable interval string. + + Returns: + Interval in seconds. + + Raises: + ValueError: If the interval string cannot be parsed. + """ + s = interval_str.strip().lower() + + # Handle aliases + if s == "daily": + return 86400 + if s == "hourly": + return 3600 + if s == "weekly": + return 604800 + + # Handle "every monday", "every tuesday", etc. (treat as weekly = 7 days) + day_names = ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + match_day = re.match(r"every\s+(" + "|".join(day_names) + r")\b", s) + if match_day: + return 604800 # 7 days in seconds + + # Try "every Xunit" pattern + match = re.match( + r"every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|w|wk|weeks?)", + s, + ) + if match: + value = int(match.group(1)) + unit = match.group(2) + if unit.startswith("s"): + return value + elif unit.startswith("m"): + return value * 60 + elif unit.startswith("h"): + return value * 3600 + elif unit.startswith("d"): + return value * 86400 + elif unit.startswith("w"): + return value * 604800 + + # Try bare "Xh", "Xm", etc. + match = re.match(r"(\d+)\s*(s|m|h|d|w)", s) + if match: + value = int(match.group(1)) + unit = match.group(2) + if unit == "s": + return value + elif unit == "m": + return value * 60 + elif unit == "h": + return value * 3600 + elif unit == "d": + return value * 86400 + elif unit == "w": + return value * 604800 + + raise ValueError( + f"Cannot parse interval: '{interval_str}'. " + "Use formats like 'every 30m', 'every 6h', 'every 2d', " + "'every 2w', 'every monday', 'daily', 'hourly', 'weekly'." + ) + + +# ── ScheduleConfig ─────────────────────────────────────────────────────────── + + +@dataclass +class ScheduleConfig: + """Parsed schedule configuration from natural language input.""" + + interval_seconds: int = 0 + time_of_day: Optional[str] = None # "HH:MM" 24h format + start_hour: Optional[int] = None # window start (0-23) + end_hour: Optional[int] = None # window end (0-23) + days_of_week: Optional[List[int]] = None # 0=Mon..6=Sun + description: str = "" + raw_input: str = "" + + def to_json(self) -> str: + """Serialize to JSON string.""" + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, s: str) -> "ScheduleConfig": + """Deserialize from JSON string. + + Raises: + ValueError: if the JSON is malformed or contains unknown keys — + a silently-empty config would mean a schedule that never fires. + """ + if not s: + return cls() + try: + data = json.loads(s) + return cls(**data) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError( + f"Invalid schedule_config JSON ({e}); raw value: {s!r}. " + "Delete and recreate the schedule, or fix the row in " + "the chat database (scheduled_tasks.schedule_config)." + ) from e + + +# ── Time parsing helpers ───────────────────────────────────────────────────── + + +def _parse_time(text: str) -> Optional[str]: + """Parse a time string into HH:MM 24-hour format. + + Supports: "9pm", "9:30pm", "9am", "noon", "midnight", "21:00". + + Args: + text: Time string to parse. + + Returns: + "HH:MM" string or None if not parseable. + """ + text = text.strip().lower() + + if text == "noon": + return "12:00" + if text == "midnight": + return "00:00" + + # 24-hour format "HH:MM" + m = re.match(r"^(\d{1,2}):(\d{2})$", text) + if m: + h, mi = int(m.group(1)), int(m.group(2)) + if 0 <= h <= 23 and 0 <= mi <= 59: + return f"{h:02d}:{mi:02d}" + + # 12-hour format with optional minutes: "9pm", "9:30am" + m = re.match(r"^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$", text) + if m: + h = int(m.group(1)) + mi = int(m.group(2)) if m.group(2) else 0 + period = m.group(3) + if h == 12: + h = 0 if period == "am" else 12 + elif period == "pm": + h += 12 + if 0 <= h <= 23 and 0 <= mi <= 59: + return f"{h:02d}:{mi:02d}" + + return None + + +def _format_time_12h(time_24: str) -> str: + """Convert HH:MM to human-readable 12-hour format. + + Args: + time_24: Time in "HH:MM" 24-hour format. + + Returns: + Human-readable string like "9:00 AM" or "3:30 PM". + """ + h, m = map(int, time_24.split(":")) + if h == 0: + return f"12:{m:02d} AM" + elif h < 12: + return f"{h}:{m:02d} AM" + elif h == 12: + return f"12:{m:02d} PM" + else: + return f"{h - 12}:{m:02d} PM" + + +def _format_interval_human(seconds: int) -> str: + """Convert interval seconds to a human-readable string. + + Args: + seconds: Interval in seconds. + + Returns: + Human-readable string like "30 minutes", "1 hour", "2 hours". + """ + if seconds < 60: + return f"{seconds} second{'s' if seconds != 1 else ''}" + elif seconds < 3600: + mins = seconds // 60 + return f"{mins} minute{'s' if mins != 1 else ''}" + elif seconds < 86400: + hours = seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''}" + elif seconds < 604800: + days = seconds // 86400 + return f"{days} day{'s' if days != 1 else ''}" + else: + weeks = seconds // 604800 + return f"{weeks} week{'s' if weeks != 1 else ''}" + + +_DAY_NAMES_DISPLAY = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +] + + +def _format_days(days: List[int]) -> str: + """Format a list of day indices into a human-readable string. + + Args: + days: List of day indices (0=Monday through 6=Sunday). + + Returns: + Human-readable string like "Monday", "weekdays", "Mon, Wed, Fri". + """ + days_sorted = sorted(days) + if days_sorted == [0, 1, 2, 3, 4]: + return "weekdays" + if days_sorted == [5, 6]: + return "weekends" + if days_sorted == list(range(7)): + return "every day" + return ", ".join(_DAY_NAMES_DISPLAY[d] for d in days_sorted) + + +# ── Natural language schedule parser ───────────────────────────────────────── + + +def parse_schedule_input(text: str) -> ScheduleConfig: + """Parse a natural language schedule description into a ScheduleConfig. + + Handles inputs such as: + - Simple intervals: "every 30m", "daily", "hourly", "weekly" + - Time-of-day: "daily at 9pm", "at 9:30am", "every day at 10am" + - Day + time: "every monday at 3pm", "weekdays at 10am", + "weekends at noon" + - Windowed: "every hour from 8am to 6pm", + "every 2 hours from 8am to 6pm on weekdays", + "every 30m from 9am to 5pm" + + Args: + text: Natural language schedule description. + + Returns: + ScheduleConfig with parsed fields. If the input cannot be parsed, + interval_seconds will be 0 and description will indicate the error. + """ + config = ScheduleConfig(raw_input=text) + s = text.strip().lower() + + if not s: + config.description = "Could not parse schedule: empty input" + return config + + # ── 1. Extract time-of-day: "at HH:MM", "at Ham/pm", "at noon" ── + time_match = re.search( + r"\bat\s+(noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\d{1,2}:\d{2})\b", s + ) + if time_match: + parsed_time = _parse_time(time_match.group(1)) + if parsed_time: + config.time_of_day = parsed_time + # Remove the matched portion so it doesn't interfere with interval parsing + s = s[: time_match.start()] + s[time_match.end() :] + + # ── 2. Extract window: "from Ham/pm to Ham/pm" ── + window_match = re.search( + r"\bfrom\s+(noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s+" + r"to\s+(noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\b", + s, + ) + if window_match: + start_time = _parse_time(window_match.group(1)) + end_time = _parse_time(window_match.group(2)) + if start_time and end_time: + config.start_hour = int(start_time.split(":")[0]) + config.end_hour = int(end_time.split(":")[0]) + s = s[: window_match.start()] + s[window_match.end() :] + + # ── 3. Extract days ── + # "weekdays" + if re.search(r"\bweekdays?\b", s): + config.days_of_week = [0, 1, 2, 3, 4] + s = re.sub(r"\bon\s+weekdays?\b", "", s) + s = re.sub(r"\bweekdays?\b", "", s) + # "weekends" + elif re.search(r"\bweekends?\b", s): + config.days_of_week = [5, 6] + s = re.sub(r"\bon\s+weekends?\b", "", s) + s = re.sub(r"\bweekends?\b", "", s) + # "mon-fri" style ranges + elif re.search( + r"\b(mon|tue|wed|thu|fri|sat|sun)-(mon|tue|wed|thu|fri|sat|sun)\b", s + ): + range_match = re.search( + r"\b(mon|tue|wed|thu|fri|sat|sun)-(mon|tue|wed|thu|fri|sat|sun)\b", s + ) + if range_match: + start_day = _ALL_DAY_NAMES[range_match.group(1)] + end_day = _ALL_DAY_NAMES[range_match.group(2)] + if start_day <= end_day: + config.days_of_week = list(range(start_day, end_day + 1)) + else: + config.days_of_week = list(range(start_day, 7)) + list( + range(0, end_day + 1) + ) + s = s[: range_match.start()] + s[range_match.end() :] + else: + # Individual day names: "on monday and wednesday", "every monday" + # Also handle "on monday, wednesday, and friday" + found_days = [] + for name, idx in _ALL_DAY_NAMES.items(): + if re.search(r"\b" + name + r"\b", s): + if idx not in found_days: + found_days.append(idx) + if found_days: + config.days_of_week = sorted(found_days) + # Remove day references from remaining string + for name in _ALL_DAY_NAMES: + s = re.sub(r"\bevery\s+" + name + r"\b", "every", s) + s = re.sub(r"\bon\s+" + name + r"\b", "", s) + s = re.sub(r"\b" + name + r"\b", "", s) + + # Clean up residual connectors + s = re.sub(r"\bon\s*$", "", s) + s = re.sub(r"\band\b", "", s) + s = s.strip().strip(",").strip() + + # ── 4. Extract interval ── + # "every Xunit" pattern + interval_match = re.match( + r"every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|w|wk|weeks?)", + s, + ) + if interval_match: + value = int(interval_match.group(1)) + unit = interval_match.group(2) + if unit.startswith("s"): + config.interval_seconds = value + elif unit.startswith("m"): + config.interval_seconds = value * 60 + elif unit.startswith("h"): + config.interval_seconds = value * 3600 + elif unit.startswith("d"): + config.interval_seconds = value * 86400 + elif unit.startswith("w"): + config.interval_seconds = value * 604800 + elif re.search(r"\bevery\s+second\b", s): + config.interval_seconds = 1 + elif re.search(r"\bevery\s+minute\b", s): + config.interval_seconds = 60 + elif re.search(r"\bevery\s+hour\b", s): + config.interval_seconds = 3600 + elif re.search(r"\bevery\s+day\b", s) or re.search(r"\bdaily\b", s): + config.interval_seconds = 86400 + elif re.search(r"\bevery\s+week\b", s): + config.interval_seconds = 604800 + elif re.search(r"\bhourly\b", s): + config.interval_seconds = 3600 + elif re.search(r"\bweekly\b", s): + config.interval_seconds = 604800 + elif re.search(r"\bminutely\b", s): + config.interval_seconds = 60 + elif ( + re.search(r"\bevery\b", s) + and config.days_of_week + and len(config.days_of_week) == 1 + ): + # "every monday" style -> weekly + config.interval_seconds = 604800 + else: + # Try bare "Xh", "Xm" patterns + bare_match = re.match(r"(\d+)\s*(s|m|h|d|w)", s) + if bare_match: + value = int(bare_match.group(1)) + unit = bare_match.group(2) + if unit == "s": + config.interval_seconds = value + elif unit == "m": + config.interval_seconds = value * 60 + elif unit == "h": + config.interval_seconds = value * 3600 + elif unit == "d": + config.interval_seconds = value * 86400 + elif unit == "w": + config.interval_seconds = value * 604800 + + # ── 5. Default: if time_of_day set but no interval, default to daily ── + if config.time_of_day and config.interval_seconds == 0: + if config.days_of_week and len(config.days_of_week) == 1: + config.interval_seconds = 604800 # weekly for single day + else: + config.interval_seconds = 86400 # daily + + # ── 6. If days are set and interval is daily but only 1 day, use weekly ── + if ( + config.days_of_week + and len(config.days_of_week) == 1 + and config.interval_seconds == 86400 + ): + config.interval_seconds = 604800 + + # ── 7. Build human-readable description ── + if config.interval_seconds > 0: + config.description = _build_description(config) + else: + config.description = f"Could not parse schedule: '{text}'" + + return config + + +def _build_description(config: ScheduleConfig) -> str: + """Build a human-readable description from a ScheduleConfig. + + Args: + config: Parsed schedule configuration. + + Returns: + Human-readable description string. + """ + parts = [] + + # Interval part + if config.start_hour is not None: + parts.append(f"Every {_format_interval_human(config.interval_seconds)}") + elif config.time_of_day: + if config.days_of_week and len(config.days_of_week) == 1: + day_name = _DAY_NAMES_DISPLAY[config.days_of_week[0]] + parts.append(f"Every {day_name}") + elif config.interval_seconds == 86400 or ( + config.days_of_week and len(config.days_of_week) > 1 + ): + parts.append("Daily") + else: + parts.append("Daily") + else: + parts.append(f"Every {_format_interval_human(config.interval_seconds)}") + + # Time part + if config.time_of_day and config.start_hour is None: + parts.append(f"at {_format_time_12h(config.time_of_day)}") + + # Window part + if config.start_hour is not None: + start_str = _format_time_12h(f"{config.start_hour:02d}:00") + end_h = config.end_hour if config.end_hour is not None else 24 + end_str = _format_time_12h(f"{end_h:02d}:00") if end_h < 24 else "12:00 AM" + parts.append(f"{start_str} - {end_str}") + + # Days part + if config.days_of_week: + days_str = _format_days(config.days_of_week) + # Avoid duplicating if already in the interval part + if config.time_of_day and len(config.days_of_week) == 1: + pass # Already handled above: "Every Monday at ..." + else: + parts.append(days_str) + + return ", ".join(parts) + + +# ── Next-run computation ───────────────────────────────────────────────────── + + +def compute_next_run(config: ScheduleConfig, after: datetime = None) -> datetime: + """Compute the next run time based on schedule config. + + Args: + config: Parsed schedule configuration. + after: Reference time (defaults to now UTC). + + Returns: + Next run datetime in UTC. + """ + now = after or datetime.now(timezone.utc) + + if config.time_of_day and config.start_hour is None: + # Fixed time schedule: "daily at 9pm", "every monday at 3pm" + hour, minute = map(int, config.time_of_day.split(":")) + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate <= now: + candidate += timedelta(days=1) + # Advance to valid day + if config.days_of_week: + while candidate.weekday() not in config.days_of_week: + candidate += timedelta(days=1) + return candidate + + elif config.start_hour is not None: + # Windowed schedule: "every hour from 8am to 6pm" + candidate = now + timedelta(seconds=config.interval_seconds) + end_h = config.end_hour if config.end_hour is not None else 24 + + # If past end of window or before start, jump to start of next window + if candidate.hour >= end_h or candidate.hour < config.start_hour: + # Check if today's window hasn't started yet + today_start = now.replace( + hour=config.start_hour, minute=0, second=0, microsecond=0 + ) + if now < today_start: + candidate = today_start + else: + candidate = (now + timedelta(days=1)).replace( + hour=config.start_hour, minute=0, second=0, microsecond=0 + ) + + # Skip invalid days + if config.days_of_week: + while candidate.weekday() not in config.days_of_week: + candidate += timedelta(days=1) + candidate = candidate.replace( + hour=config.start_hour, minute=0, second=0, microsecond=0 + ) + + return candidate + + elif config.days_of_week: + # Day-specific without fixed time: find the next valid day + candidate = now + timedelta(seconds=config.interval_seconds) + # Advance to the next valid day of week if needed + for _ in range(7): + if candidate.weekday() in config.days_of_week: + return candidate + candidate += timedelta(days=1) + # For day-based schedules, reset to same time-of-day + if config.interval_seconds >= 86400: + candidate = candidate.replace( + hour=now.hour, minute=now.minute, second=0, microsecond=0 + ) + return candidate + + else: + # Simple interval + return now + timedelta(seconds=config.interval_seconds) + + +# ── ScheduledTask ──────────────────────────────────────────────────────────── + + +class ScheduledTask: + """Represents a single scheduled task with its timer state.""" + + def __init__( + self, + task_id: str, + name: str, + interval_seconds: int, + prompt: str, + status: str = "active", + created_at: str = None, + last_run_at: str = None, + next_run_at: str = None, + last_result: str = None, + run_count: int = 0, + error_count: int = 0, + session_id: str = None, + schedule_config: str = None, + ): + self.id = task_id + self.name = name + self.interval_seconds = interval_seconds + self.prompt = prompt + self.status = status + self.created_at = created_at or datetime.now(timezone.utc).isoformat() + self.last_run_at = last_run_at + self.next_run_at = next_run_at + self.last_result = last_result + self.run_count = run_count + self.error_count = error_count + self.session_id = session_id + self.schedule_config = schedule_config + self._timer_task: Optional[asyncio.Task] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API responses.""" + return { + "id": self.id, + "name": self.name, + "interval_seconds": self.interval_seconds, + "prompt": self.prompt, + "status": self.status, + "created_at": self.created_at, + "last_run_at": self.last_run_at, + "next_run_at": self.next_run_at, + "last_result": self.last_result, + "run_count": self.run_count, + "error_count": self.error_count, + "session_id": self.session_id, + "schedule_config": self.schedule_config, + } + + +class Scheduler: + """Async scheduler that manages recurring tasks. + + The scheduler persists tasks in the ChatDatabase's scheduled_tasks table + and uses asyncio timers to fire them at the configured intervals. + + Usage:: + + scheduler = Scheduler(db, executor=run_prompt) + await scheduler.start() # Load & start persisted tasks + await scheduler.create_task("daily-report", "every 24h", "Summarize today's news") + ... + await scheduler.shutdown() # Cancel all timers + """ + + def __init__(self, db, executor: Callable, max_concurrent_runs: int = 1): + """Initialize the scheduler. + + Args: + db: ChatDatabase instance with scheduled_tasks table. + executor: Async callable(prompt: str) -> str that executes a task. + max_concurrent_runs: Maximum number of task executions allowed to + run at the same time (default 1 — the local LLM backend is + single-tenant per model slot). + + Raises: + ValueError: if executor is missing or not callable — a scheduler + without an executor would silently no-op every run. + """ + if executor is None or not callable(executor): + raise ValueError( + "Scheduler requires a callable executor(prompt) -> str. " + "Pass the agent-execution coroutine from the server lifespan " + "(src/gaia/ui/server.py) or a fake executor in tests." + ) + if max_concurrent_runs < 1: + raise ValueError( + f"max_concurrent_runs must be >= 1, got {max_concurrent_runs}" + ) + self._db = db + self._executor = executor + self._max_concurrent_runs = max_concurrent_runs + self._run_semaphore: Optional[asyncio.Semaphore] = None + self._tasks: Dict[str, ScheduledTask] = {} + self._lock = asyncio.Lock() + self._running = False + logger.info("Scheduler initialized") + + @property + def running(self) -> bool: + """Whether the scheduler is currently running.""" + return self._running + + @property + def tasks(self) -> Dict[str, ScheduledTask]: + """Active scheduled tasks by name.""" + return dict(self._tasks) + + async def start(self): + """Start the scheduler: load persisted tasks and start timers. + + Raises: + Exception: any database/schema error from loading persisted + tasks propagates — a scheduler that silently boots with zero + tasks would hide data loss. + """ + self._running = True + # Semaphore must be created on the running event loop. + self._run_semaphore = asyncio.Semaphore(self._max_concurrent_runs) + await self._load_tasks() + logger.info("Scheduler started with %d task(s)", len(self._tasks)) + + async def shutdown(self): + """Stop the scheduler: cancel all timers cleanly.""" + self._running = False + async with self._lock: + for task in self._tasks.values(): + if task._timer_task and not task._timer_task.done(): + task._timer_task.cancel() + try: + await task._timer_task + except asyncio.CancelledError: + pass + task._timer_task = None + logger.info("Scheduler shut down, all timers cancelled") + + async def create_task( + self, + name: str, + interval: str, + prompt: str, + ) -> Dict[str, Any]: + """Create a new scheduled task. + + Tries the natural-language parser first (``parse_schedule_input``). + Falls back to the simpler ``parse_interval`` for backward + compatibility. + + Args: + name: Unique task name. + interval: Human-readable interval (e.g. "every 6h", + "daily at 9pm", "every monday at 3pm"). + prompt: The prompt to execute on each run. + + Returns: + Task dict with status info. + + Raises: + ValueError: If name is duplicate or interval is invalid. + """ + # Try natural-language parser first + config = parse_schedule_input(interval) + if config.interval_seconds > 0: + interval_seconds = config.interval_seconds + schedule_config_json = config.to_json() + else: + # Fall back to legacy parse_interval + interval_seconds = parse_interval(interval) + config = None + schedule_config_json = None + + async with self._lock: + if name in self._tasks: + raise ValueError(f"Task with name '{name}' already exists") + + task_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + + if config: + next_run = compute_next_run(config, after=now) + else: + next_run = now + timedelta(seconds=interval_seconds) + + task = ScheduledTask( + task_id=task_id, + name=name, + interval_seconds=interval_seconds, + prompt=prompt, + status="active", + created_at=now.isoformat(), + next_run_at=next_run.isoformat(), + schedule_config=schedule_config_json, + ) + + # Persist to database + self._db_create_task(task) + + # Start timer + self._tasks[name] = task + if self._running: + task._timer_task = asyncio.create_task( + self._run_loop(task), name=f"sched:{name}" + ) + + logger.info("Created scheduled task '%s' (every %ds)", name, interval_seconds) + return task.to_dict() + + async def cancel_task(self, name: str) -> Dict[str, Any]: + """Cancel a scheduled task. + + Args: + name: Task name. + + Returns: + Updated task dict. + + Raises: + KeyError: If task not found. + """ + async with self._lock: + task = self._tasks.get(name) + if not task: + raise KeyError(f"Task '{name}' not found") + + # Cancel timer + if task._timer_task and not task._timer_task.done(): + task._timer_task.cancel() + try: + await task._timer_task + except asyncio.CancelledError: + pass + task._timer_task = None + + task.status = "cancelled" + task.next_run_at = None + self._db_update_task(task) + + logger.info("Cancelled scheduled task '%s'", name) + return task.to_dict() + + async def pause_task(self, name: str) -> Dict[str, Any]: + """Pause a scheduled task (keeps it in the list but stops timer). + + Args: + name: Task name. + + Returns: + Updated task dict. + + Raises: + KeyError: If task not found. + """ + async with self._lock: + task = self._tasks.get(name) + if not task: + raise KeyError(f"Task '{name}' not found") + + if task.status != "active": + raise ValueError(f"Task '{name}' is not active (status: {task.status})") + + # Cancel timer + if task._timer_task and not task._timer_task.done(): + task._timer_task.cancel() + try: + await task._timer_task + except asyncio.CancelledError: + pass + task._timer_task = None + + task.status = "paused" + task.next_run_at = None + self._db_update_task(task) + + logger.info("Paused scheduled task '%s'", name) + return task.to_dict() + + async def resume_task(self, name: str) -> Dict[str, Any]: + """Resume a paused scheduled task. + + Args: + name: Task name. + + Returns: + Updated task dict. + + Raises: + KeyError: If task not found. + """ + async with self._lock: + task = self._tasks.get(name) + if not task: + raise KeyError(f"Task '{name}' not found") + + if task.status != "paused": + raise ValueError(f"Task '{name}' is not paused (status: {task.status})") + + task.status = "active" + config = ( + ScheduleConfig.from_json(task.schedule_config) + if task.schedule_config + else None + ) + if config and (config.time_of_day or config.start_hour is not None): + next_run = compute_next_run(config) + else: + next_run = datetime.now(timezone.utc) + timedelta( + seconds=task.interval_seconds + ) + task.next_run_at = next_run.isoformat() + self._db_update_task(task) + + # Restart timer + if self._running: + task._timer_task = asyncio.create_task( + self._run_loop(task), name=f"sched:{name}" + ) + + logger.info("Resumed scheduled task '%s'", name) + return task.to_dict() + + async def delete_task(self, name: str) -> bool: + """Delete a scheduled task entirely. + + Args: + name: Task name. + + Returns: + True if deleted. + + Raises: + KeyError: If task not found. + """ + async with self._lock: + task = self._tasks.get(name) + if not task: + raise KeyError(f"Task '{name}' not found") + + # Cancel timer + if task._timer_task and not task._timer_task.done(): + task._timer_task.cancel() + try: + await task._timer_task + except asyncio.CancelledError: + pass + + self._db_delete_task(task.id) + del self._tasks[name] + + logger.info("Deleted scheduled task '%s'", name) + return True + + def get_task(self, name: str) -> Optional[Dict[str, Any]]: + """Get task info by name. + + Args: + name: Task name. + + Returns: + Task dict or None. + """ + task = self._tasks.get(name) + return task.to_dict() if task else None + + def list_tasks(self) -> List[Dict[str, Any]]: + """List all scheduled tasks. + + Returns: + List of task dicts. + """ + return [t.to_dict() for t in self._tasks.values()] + + def get_task_results(self, name: str, limit: int = 20) -> List[Dict[str, Any]]: + """Get past run results for a task. + + Args: + name: Task name. + limit: Maximum number of results to return. + + Returns: + List of result dicts with timestamp and output. + """ + task = self._tasks.get(name) + if not task: + return [] + + return self._db_get_results(task.id, limit) + + # ── Internal: timer loop ────────────────────────────────────────────── + + async def _run_loop(self, task: ScheduledTask): + """Run the timer loop for a single task.""" + try: + while self._running and task.status == "active": + config = ( + ScheduleConfig.from_json(task.schedule_config) + if task.schedule_config + else None + ) + if config and (config.time_of_day or config.start_hour is not None): + next_dt = compute_next_run(config) + sleep_secs = max( + 0, (next_dt - datetime.now(timezone.utc)).total_seconds() + ) + else: + sleep_secs = task.interval_seconds + + await asyncio.sleep(sleep_secs) + + if not self._running or task.status != "active": + break + + await self._execute_task(task) + except asyncio.CancelledError: + logger.debug("Timer cancelled for task '%s'", task.name) + raise + + async def _execute_task(self, task: ScheduledTask): + """Execute a single task run. + + If the database supports sessions (i.e. is a full ChatDatabase), + each schedule gets a dedicated chat session. Every run adds a + system divider, the prompt as a user message, and the LLM + response as an assistant message -- so users can open the session + and see the full history of scheduled runs. + """ + now = datetime.now(timezone.utc) + task.last_run_at = now.isoformat() + task.run_count += 1 + + logger.info( + "Executing scheduled task '%s' (run #%d)", task.name, task.run_count + ) + + # ── Create / reuse chat session for this schedule ──────────── + has_sessions = hasattr(self._db, "create_session") + if has_sessions and not task.session_id: + try: + session = self._db.create_session(title=f"Schedule: {task.name}") + task.session_id = session["id"] + logger.info( + "Created session %s for schedule '%s'", + task.session_id, + task.name, + ) + except Exception as exc: + # Non-fatal by design: a failed history write must not kill + # the run — but it must be loud. + logger.error( + "Failed to create session for schedule '%s': %s", + task.name, + exc, + exc_info=True, + ) + + # ── Add run divider + user message ─────────────────────────── + if task.session_id and has_sessions: + try: + ts = now.strftime("%Y-%m-%d %H:%M UTC") + self._db.add_message( + task.session_id, + "system", + f"[schedule-run] Run #{task.run_count} \u00b7 {ts}", + ) + self._db.add_message(task.session_id, "user", task.prompt) + except Exception as exc: + logger.error( + "Failed to add session messages for schedule '%s': %s", + task.name, + exc, + exc_info=True, + ) + + # ── Execute (bounded by the concurrency semaphore) ─────────── + result = None + error = None + try: + async with self._run_semaphore: + result = await self._executor(task.prompt) + except Exception as e: + error = str(e) + task.error_count += 1 + logger.error( + "Scheduled task '%s' failed (run #%d): %s", + task.name, + task.run_count, + e, + exc_info=True, + ) + + # ── Store assistant response in session ────────────────────── + if task.session_id and has_sessions: + try: + content = f"Error: {error}" if error else (result or "(no output)") + self._db.add_message(task.session_id, "assistant", content) + except Exception as exc: + logger.error( + "Failed to store response for schedule '%s': %s", + task.name, + exc, + exc_info=True, + ) + + # Update next run + config = ( + ScheduleConfig.from_json(task.schedule_config) + if task.schedule_config + else None + ) + if config: + next_run = compute_next_run(config) + else: + next_run = datetime.now(timezone.utc) + timedelta( + seconds=task.interval_seconds + ) + task.next_run_at = next_run.isoformat() + task.last_result = error if error else (result or "completed") + + # Persist state + self._db_update_task(task) + self._db_store_result(task.id, now.isoformat(), result, error) + + # ── Internal: database operations ───────────────────────────────────── + + async def _load_tasks(self): + """Load persisted tasks from database and start active timers. + + Database/schema errors propagate — a scheduler that silently boots + with zero tasks hides data loss (CLAUDE.md: no silent fallbacks). + """ + rows = self._db_list_tasks() + for row in rows: + try: + task = ScheduledTask( + task_id=row["id"], + name=row["name"], + interval_seconds=row["interval_seconds"], + prompt=row["prompt"], + status=row["status"], + created_at=row.get("created_at"), + last_run_at=row.get("last_run_at"), + next_run_at=row.get("next_run_at"), + last_result=row.get("last_result"), + run_count=row.get("run_count", 0), + error_count=row.get("error_count", 0), + session_id=row.get("session_id"), + schedule_config=row.get("schedule_config"), + ) + except KeyError as e: + raise RuntimeError( + f"scheduled_tasks row {row.get('id')!r} is missing column {e}; " + "the schedule schema is out of date — check " + "src/gaia/ui/database.py." + ) from e + if task.schedule_config: + # Validate eagerly so a corrupt row fails the boot loudly + # instead of killing the timer coroutine unobserved later. + ScheduleConfig.from_json(task.schedule_config) + self._tasks[task.name] = task + + if task.status == "active" and self._running: + task._timer_task = asyncio.create_task( + self._run_loop(task), name=f"sched:{task.name}" + ) + logger.info( + "Restored scheduled task '%s' (every %ds)", + task.name, + task.interval_seconds, + ) + + def _db_create_task(self, task: ScheduledTask): + """Insert a new task row via the public ChatDatabase API.""" + self._db.create_scheduled_task( + { + "id": task.id, + "name": task.name, + "interval_seconds": task.interval_seconds, + "prompt": task.prompt, + "status": task.status, + "created_at": task.created_at, + "next_run_at": task.next_run_at, + "run_count": task.run_count, + "error_count": task.error_count, + "session_id": task.session_id, + "schedule_config": task.schedule_config, + } + ) + + def _db_update_task(self, task: ScheduledTask): + """Update an existing task row via the public ChatDatabase API.""" + self._db.update_scheduled_task( + task.id, + status=task.status, + last_run_at=task.last_run_at, + next_run_at=task.next_run_at, + last_result=task.last_result, + run_count=task.run_count, + error_count=task.error_count, + session_id=task.session_id, + schedule_config=task.schedule_config, + ) + + def _db_delete_task(self, task_id: str): + """Delete a task row and its results.""" + self._db.delete_scheduled_task(task_id) + + def _db_list_tasks(self) -> List[Dict[str, Any]]: + """Load all tasks from database.""" + return self._db.list_scheduled_tasks() + + def _db_store_result( + self, task_id: str, timestamp: str, result: str = None, error: str = None + ): + """Store a task execution result.""" + self._db.store_schedule_result( + str(uuid.uuid4()), task_id, timestamp, result, error + ) + + def _db_get_results(self, task_id: str, limit: int = 20) -> List[Dict[str, Any]]: + """Get past results for a task, newest first.""" + return self._db.get_schedule_results(task_id, limit) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py new file mode 100644 index 000000000..f57d12bce --- /dev/null +++ b/tests/unit/test_scheduler.py @@ -0,0 +1,485 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Unit tests for the GAIA Scheduler (M5: Scheduled Autonomy). + +Tests cover: +- Interval string parsing +- Task creation, pause, resume, cancel, delete +- Timer loop execution +- Database persistence +- Shutdown cleanup +""" + +import asyncio + +import pytest +import pytest_asyncio + +from gaia.ui.database import ChatDatabase +from gaia.ui.scheduler import ( + ScheduleConfig, + ScheduledTask, + Scheduler, + parse_interval, +) + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +async def fake_executor(prompt: str) -> str: + """Minimal executor for tests that don't care about execution results.""" + return f"ran: {prompt}" + + +@pytest.fixture +def fake_db(): + db = ChatDatabase(":memory:") + yield db + db.close() + + +@pytest_asyncio.fixture +async def scheduler(fake_db): + sched = Scheduler(db=fake_db, executor=fake_executor) + await sched.start() + yield sched + await sched.shutdown() + + +# ── parse_interval tests ───────────────────────────────────────────────────── + + +class TestParseInterval: + """Test the interval string parser.""" + + def test_every_minutes(self): + assert parse_interval("every 30m") == 1800 + + def test_every_hours(self): + assert parse_interval("every 6h") == 21600 + + def test_every_seconds(self): + assert parse_interval("every 30s") == 30 + + def test_every_days(self): + assert parse_interval("every 2d") == 172800 + + def test_every_minutes_long(self): + assert parse_interval("every 5 minutes") == 300 + + def test_every_hours_long(self): + assert parse_interval("every 2 hours") == 7200 + + def test_daily_alias(self): + assert parse_interval("daily") == 86400 + + def test_hourly_alias(self): + assert parse_interval("hourly") == 3600 + + def test_bare_shorthand(self): + assert parse_interval("30m") == 1800 + + def test_bare_hours(self): + assert parse_interval("6h") == 21600 + + def test_case_insensitive(self): + assert parse_interval("Every 30M") == 1800 + + def test_invalid_interval(self): + with pytest.raises(ValueError, match="Cannot parse interval"): + parse_interval("next tuesday") + + def test_empty_string(self): + with pytest.raises(ValueError): + parse_interval("") + + def test_every_24h(self): + assert parse_interval("every 24h") == 86400 + + +# ── ScheduledTask tests ────────────────────────────────────────────────────── + + +class TestScheduledTask: + """Test the ScheduledTask data class.""" + + def test_to_dict(self): + task = ScheduledTask( + task_id="abc123", + name="test-task", + interval_seconds=3600, + prompt="Do something", + ) + d = task.to_dict() + assert d["id"] == "abc123" + assert d["name"] == "test-task" + assert d["interval_seconds"] == 3600 + assert d["prompt"] == "Do something" + assert d["status"] == "active" + assert d["run_count"] == 0 + assert d["error_count"] == 0 + + def test_default_status(self): + task = ScheduledTask(task_id="x", name="t", interval_seconds=60, prompt="p") + assert task.status == "active" + + +# ── Scheduler create/list tests ────────────────────────────────────────────── + + +class TestSchedulerCreate: + """Test task creation and listing.""" + + @pytest.mark.asyncio + async def test_create_task(self, scheduler): + result = await scheduler.create_task("my-task", "every 30m", "Do thing") + assert result["name"] == "my-task" + assert result["interval_seconds"] == 1800 + assert result["prompt"] == "Do thing" + assert result["status"] == "active" + assert result["next_run_at"] is not None + + @pytest.mark.asyncio + async def test_create_duplicate_name(self, scheduler): + await scheduler.create_task("dup", "every 1h", "First") + with pytest.raises(ValueError, match="already exists"): + await scheduler.create_task("dup", "every 2h", "Second") + + @pytest.mark.asyncio + async def test_create_invalid_interval(self, scheduler): + with pytest.raises(ValueError, match="Cannot parse interval"): + await scheduler.create_task("bad", "whenever", "Prompt") + + @pytest.mark.asyncio + async def test_list_tasks(self, scheduler): + await scheduler.create_task("a", "every 1h", "Prompt A") + await scheduler.create_task("b", "every 2h", "Prompt B") + tasks = scheduler.list_tasks() + assert len(tasks) == 2 + names = {t["name"] for t in tasks} + assert names == {"a", "b"} + + @pytest.mark.asyncio + async def test_get_task(self, scheduler): + await scheduler.create_task("find-me", "every 5m", "Hello") + task = scheduler.get_task("find-me") + assert task is not None + assert task["prompt"] == "Hello" + + @pytest.mark.asyncio + async def test_get_task_not_found(self, scheduler): + assert scheduler.get_task("nope") is None + + @pytest.mark.asyncio + async def test_task_persists_to_db(self, fake_db): + """Task should be written to database on creation.""" + sched = Scheduler(db=fake_db, executor=fake_executor) + await sched.start() + await sched.create_task("db-test", "every 1h", "Check DB") + + # Verify row exists via the public API + rows = [r for r in fake_db.list_scheduled_tasks() if r["name"] == "db-test"] + assert len(rows) == 1 + assert rows[0]["interval_seconds"] == 3600 + + await sched.shutdown() + + +# ── Scheduler pause/resume/cancel tests ────────────────────────────────────── + + +class TestSchedulerLifecycle: + """Test pause, resume, cancel, delete operations.""" + + @pytest.mark.asyncio + async def test_pause_task(self, scheduler): + await scheduler.create_task("pausable", "every 1h", "Test") + result = await scheduler.pause_task("pausable") + assert result["status"] == "paused" + assert result["next_run_at"] is None + + @pytest.mark.asyncio + async def test_pause_not_active(self, scheduler): + await scheduler.create_task("p", "every 1h", "Test") + await scheduler.pause_task("p") + with pytest.raises(ValueError, match="not active"): + await scheduler.pause_task("p") + + @pytest.mark.asyncio + async def test_resume_task(self, scheduler): + await scheduler.create_task("resumable", "every 1h", "Test") + await scheduler.pause_task("resumable") + result = await scheduler.resume_task("resumable") + assert result["status"] == "active" + assert result["next_run_at"] is not None + + @pytest.mark.asyncio + async def test_resume_not_paused(self, scheduler): + await scheduler.create_task("r", "every 1h", "Test") + with pytest.raises(ValueError, match="not paused"): + await scheduler.resume_task("r") + + @pytest.mark.asyncio + async def test_cancel_task(self, scheduler): + await scheduler.create_task("cancellable", "every 1h", "Test") + result = await scheduler.cancel_task("cancellable") + assert result["status"] == "cancelled" + assert result["next_run_at"] is None + + @pytest.mark.asyncio + async def test_cancel_not_found(self, scheduler): + with pytest.raises(KeyError, match="not found"): + await scheduler.cancel_task("nonexistent") + + @pytest.mark.asyncio + async def test_delete_task(self, scheduler): + await scheduler.create_task("deletable", "every 1h", "Test") + result = await scheduler.delete_task("deletable") + assert result is True + assert scheduler.get_task("deletable") is None + + @pytest.mark.asyncio + async def test_delete_not_found(self, scheduler): + with pytest.raises(KeyError, match="not found"): + await scheduler.delete_task("ghost") + + @pytest.mark.asyncio + async def test_delete_removes_from_db(self, fake_db): + sched = Scheduler(db=fake_db, executor=fake_executor) + await sched.start() + await sched.create_task("db-del", "every 1h", "Test") + await sched.delete_task("db-del") + + rows = [r for r in fake_db.list_scheduled_tasks() if r["name"] == "db-del"] + assert rows == [] + + await sched.shutdown() + + +# ── Scheduler execution tests ──────────────────────────────────────────────── + + +class TestSchedulerExecution: + """Test the timer execution loop.""" + + @pytest.mark.asyncio + async def test_executor_called(self, fake_db): + """Executor should be called when task fires.""" + results = [] + + async def mock_executor(prompt): + results.append(prompt) + return f"Executed: {prompt}" + + sched = Scheduler(db=fake_db, executor=mock_executor) + await sched.start() + + # Create a task with 1-second interval + await sched.create_task("fast", "every 1s", "Quick test") + + # Wait for it to fire at least once + await asyncio.sleep(1.5) + + assert len(results) >= 1 + assert results[0] == "Quick test" + + # Check that the task recorded the run + task = sched.get_task("fast") + assert task["run_count"] >= 1 + assert task["last_run_at"] is not None + + await sched.shutdown() + + @pytest.mark.asyncio + async def test_executor_error_recorded(self, fake_db): + """Executor errors should be caught and recorded.""" + + async def failing_executor(prompt): + raise RuntimeError("Something broke") + + sched = Scheduler(db=fake_db, executor=failing_executor) + await sched.start() + + await sched.create_task("fail", "every 1s", "Will fail") + await asyncio.sleep(1.5) + + task = sched.get_task("fail") + assert task["error_count"] >= 1 + assert "Something broke" in (task["last_result"] or "") + + # Task should still be active (errors don't stop scheduling) + assert task["status"] == "active" + + await sched.shutdown() + + @pytest.mark.asyncio + async def test_results_stored(self, fake_db): + """Execution results should be stored in schedule_results.""" + + async def mock_executor(prompt): + return "Done" + + sched = Scheduler(db=fake_db, executor=mock_executor) + await sched.start() + + await sched.create_task("track", "every 1s", "Track me") + await asyncio.sleep(1.5) + + results = sched.get_task_results("track") + assert len(results) >= 1 + assert results[0]["result"] == "Done" + assert results[0]["error"] is None + + await sched.shutdown() + + def test_scheduler_requires_executor(self, fake_db): + """A scheduler without an executor must fail loudly at construction.""" + with pytest.raises(ValueError, match="requires a callable executor"): + Scheduler(db=fake_db, executor=None) + + def test_scheduler_rejects_non_callable_executor(self, fake_db): + with pytest.raises(ValueError, match="requires a callable executor"): + Scheduler(db=fake_db, executor="not-callable") + + def test_scheduler_rejects_zero_concurrency(self, fake_db): + with pytest.raises(ValueError, match="max_concurrent_runs"): + Scheduler(db=fake_db, executor=fake_executor, max_concurrent_runs=0) + + +# ── Scheduler shutdown tests ───────────────────────────────────────────────── + + +class TestSchedulerShutdown: + """Test clean shutdown.""" + + @pytest.mark.asyncio + async def test_shutdown_cancels_timers(self, fake_db): + sched = Scheduler(db=fake_db, executor=fake_executor) + await sched.start() + + await sched.create_task("t1", "every 1h", "Long") + await sched.create_task("t2", "every 2h", "Longer") + + # Both should have active timer tasks + assert len(sched.tasks) == 2 + + await sched.shutdown() + assert not sched.running + + @pytest.mark.asyncio + async def test_shutdown_idempotent(self, fake_db): + """Calling shutdown twice should not error.""" + sched = Scheduler(db=fake_db, executor=fake_executor) + await sched.start() + await sched.shutdown() + await sched.shutdown() # Should not raise + + +# ── Scheduler persistence tests ────────────────────────────────────────────── + + +class TestSchedulerPersistence: + """Test that tasks persist across scheduler restarts.""" + + @pytest.mark.asyncio + async def test_tasks_restored_on_start(self, fake_db): + """Tasks saved to DB should be restored when scheduler starts.""" + # Create tasks with first scheduler + sched1 = Scheduler(db=fake_db, executor=fake_executor) + await sched1.start() + await sched1.create_task("persist-1", "every 1h", "First") + await sched1.create_task("persist-2", "every 2h", "Second") + await sched1.shutdown() + + # New scheduler should load them + sched2 = Scheduler(db=fake_db, executor=fake_executor) + await sched2.start() + tasks = sched2.list_tasks() + assert len(tasks) == 2 + names = {t["name"] for t in tasks} + assert names == {"persist-1", "persist-2"} + await sched2.shutdown() + + @pytest.mark.asyncio + async def test_paused_task_not_started_on_restore(self, fake_db): + """Paused tasks should be loaded but not have active timers.""" + sched1 = Scheduler(db=fake_db, executor=fake_executor) + await sched1.start() + await sched1.create_task("paused-persist", "every 1h", "P") + await sched1.pause_task("paused-persist") + await sched1.shutdown() + + sched2 = Scheduler(db=fake_db, executor=fake_executor) + await sched2.start() + task = sched2.get_task("paused-persist") + assert task["status"] == "paused" + # The internal task object should not have an active timer + internal = sched2._tasks.get("paused-persist") + assert internal._timer_task is None + await sched2.shutdown() + + +# ── Extended parse_interval tests ─────────────────────────────────────────── + + +class TestParseIntervalExtended: + """Test newly added interval formats: weekly alias, day names, and week units.""" + + def test_weekly_alias(self): + """'weekly' alias should map to 7 days (604800 seconds).""" + assert parse_interval("weekly") == 604800 + + def test_every_monday(self): + """'every monday' should be treated as weekly (604800 seconds).""" + assert parse_interval("every monday") == 604800 + + def test_every_friday(self): + """'every friday' should be treated as weekly (604800 seconds).""" + assert parse_interval("every friday") == 604800 + + def test_every_2_weeks(self): + """'every 2 weeks' should be 2 * 604800 = 1209600 seconds.""" + assert parse_interval("every 2 weeks") == 1209600 + + def test_every_2w(self): + """'every 2w' shorthand should be 1209600 seconds.""" + assert parse_interval("every 2w") == 1209600 + + def test_bare_1w(self): + """Bare '1w' shorthand should be 604800 seconds.""" + assert parse_interval("1w") == 604800 + + def test_invalid_day_name(self): + """'every someday' is not a valid day name and should raise ValueError.""" + with pytest.raises(ValueError, match="Cannot parse interval"): + parse_interval("every someday") + + def test_invalid_format(self): + """'every minute' (no number, not a day name) should raise ValueError.""" + with pytest.raises(ValueError, match="Cannot parse interval"): + parse_interval("every minute") + + +# ── ScheduleConfig fail-loudly tests ───────────────────────────────────────── + + +class TestScheduleConfigFromJson: + """Malformed persisted configs must raise, not silently never fire.""" + + def test_from_json_empty_returns_default(self): + config = ScheduleConfig.from_json("") + assert config.interval_seconds == 0 + + def test_from_json_roundtrip(self): + original = ScheduleConfig(interval_seconds=3600, time_of_day="09:00") + restored = ScheduleConfig.from_json(original.to_json()) + assert restored == original + + def test_from_json_malformed_raises(self): + with pytest.raises(ValueError, match="Invalid schedule_config"): + ScheduleConfig.from_json("{not json") + + def test_from_json_unknown_key_raises(self): + with pytest.raises(ValueError, match="Invalid schedule_config"): + ScheduleConfig.from_json('{"bogus_key": 1}') From 5b7478e2f8060cbafae63f86c08d12c8f2fa949b Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 9 Jun 2026 23:49:25 -0700 Subject: [PATCH 3/9] feat(ui): add /api/schedules REST router --- src/gaia/ui/routers/schedules.py | 241 +++++++++++++++++++++++++++++ src/gaia/ui/server.py | 2 + tests/unit/test_scheduler_api.py | 258 +++++++++++++++++++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 src/gaia/ui/routers/schedules.py create mode 100644 tests/unit/test_scheduler_api.py diff --git a/src/gaia/ui/routers/schedules.py b/src/gaia/ui/routers/schedules.py new file mode 100644 index 000000000..27a6d0875 --- /dev/null +++ b/src/gaia/ui/routers/schedules.py @@ -0,0 +1,241 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Schedule management endpoints for GAIA Agent UI. + +REST API for creating, managing, and monitoring recurring scheduled tasks. +""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from ..scheduler import Scheduler + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["schedules"]) + + +# ── Request/Response Models ────────────────────────────────────────────────── + + +class CreateScheduleRequest(BaseModel): + """Request to create a new scheduled task.""" + + name: str = Field(..., description="Unique name for the scheduled task") + interval: str = Field( + ..., description="Interval string, e.g. 'every 6h', 'every 30m', 'daily'" + ) + prompt: str = Field(..., description="The prompt to execute on each run") + + +class UpdateScheduleRequest(BaseModel): + """Request to update a scheduled task.""" + + status: Optional[str] = Field( + None, description="New status: 'paused', 'active', or 'cancelled'" + ) + + +class ScheduleResponse(BaseModel): + """A scheduled task.""" + + id: str + name: str + interval_seconds: int + prompt: str + status: str + created_at: Optional[str] = None + last_run_at: Optional[str] = None + next_run_at: Optional[str] = None + last_result: Optional[str] = None + run_count: int = 0 + error_count: int = 0 + session_id: Optional[str] = None + schedule_config: Optional[str] = None + + +class ScheduleListResponse(BaseModel): + """List of scheduled tasks.""" + + schedules: list + total: int + + +class ScheduleResultResponse(BaseModel): + """A single schedule execution result.""" + + id: str + task_id: str + executed_at: str + result: Optional[str] = None + error: Optional[str] = None + + +class ScheduleResultsResponse(BaseModel): + """List of schedule execution results.""" + + results: list + total: int + + +class ParseScheduleRequest(BaseModel): + """Request to parse a natural language schedule description.""" + + input: str = Field(..., description="Natural language schedule description") + + +class ParseScheduleResponse(BaseModel): + """Parsed schedule configuration.""" + + interval_seconds: int + time_of_day: Optional[str] = None + start_hour: Optional[int] = None + end_hour: Optional[int] = None + days_of_week: Optional[List[int]] = None + description: str + next_run_at: Optional[str] = None + valid: bool # True if the schedule could be parsed + + +# ── Dependency ─────────────────────────────────────────────────────────────── + + +def get_scheduler(request: Request) -> Scheduler: + """Return the Scheduler instance stored on ``app.state``.""" + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler is None: + raise HTTPException( + status_code=503, + detail=( + "Scheduler not running — the server may still be starting up, " + "or the lifespan failed to start it; check the server logs." + ), + ) + return scheduler + + +# ── Endpoints ──────────────────────────────────────────────────────────────── + + +@router.post("/api/schedules/parse", response_model=ParseScheduleResponse) +async def parse_schedule(request: ParseScheduleRequest): + """Parse a natural language schedule description into structured config.""" + from datetime import datetime, timezone + + from ..scheduler import ScheduleConfig, compute_next_run, parse_schedule_input + + config = parse_schedule_input(request.input) + next_run = None + if config.interval_seconds > 0: + next_dt = compute_next_run(config) + next_run = next_dt.isoformat() + + return ParseScheduleResponse( + interval_seconds=config.interval_seconds, + time_of_day=config.time_of_day, + start_hour=config.start_hour, + end_hour=config.end_hour, + days_of_week=config.days_of_week, + description=config.description, + next_run_at=next_run, + valid=config.interval_seconds > 0, + ) + + +@router.post("/api/schedules", response_model=ScheduleResponse) +async def create_schedule( + request: CreateScheduleRequest, + scheduler: Scheduler = Depends(get_scheduler), +): + """Create a new scheduled task.""" + try: + task = await scheduler.create_task( + name=request.name, + interval=request.interval, + prompt=request.prompt, + ) + return task + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to create schedule: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to create schedule") + + +@router.get("/api/schedules", response_model=ScheduleListResponse) +async def list_schedules( + scheduler: Scheduler = Depends(get_scheduler), +): + """List all scheduled tasks.""" + tasks = scheduler.list_tasks() + return ScheduleListResponse(schedules=tasks, total=len(tasks)) + + +@router.get("/api/schedules/{name}", response_model=ScheduleResponse) +async def get_schedule( + name: str, + scheduler: Scheduler = Depends(get_scheduler), +): + """Get a specific scheduled task.""" + task = scheduler.get_task(name) + if not task: + raise HTTPException(status_code=404, detail=f"Schedule '{name}' not found") + return task + + +@router.put("/api/schedules/{name}", response_model=ScheduleResponse) +async def update_schedule( + name: str, + request: UpdateScheduleRequest, + scheduler: Scheduler = Depends(get_scheduler), +): + """Update a scheduled task (pause, resume, cancel).""" + try: + if request.status == "paused": + return await scheduler.pause_task(name) + elif request.status == "active": + return await scheduler.resume_task(name) + elif request.status == "cancelled": + return await scheduler.cancel_task(name) + else: + raise HTTPException( + status_code=400, + detail=f"Invalid status: '{request.status}'. Use 'paused', 'active', or 'cancelled'.", + ) + except KeyError: + raise HTTPException(status_code=404, detail=f"Schedule '{name}' not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/api/schedules/{name}") +async def delete_schedule( + name: str, + scheduler: Scheduler = Depends(get_scheduler), +): + """Delete a scheduled task.""" + try: + await scheduler.delete_task(name) + return {"deleted": True} + except KeyError: + raise HTTPException(status_code=404, detail=f"Schedule '{name}' not found") + + +@router.get("/api/schedules/{name}/results", response_model=ScheduleResultsResponse) +async def get_schedule_results( + name: str, + limit: int = 20, + scheduler: Scheduler = Depends(get_scheduler), +): + """Get past execution results for a scheduled task.""" + task = scheduler.get_task(name) + if not task: + raise HTTPException(status_code=404, detail=f"Schedule '{name}' not found") + + limit = max(1, min(limit, 100)) + results = scheduler.get_task_results(name, limit=limit) + return ScheduleResultsResponse(results=results, total=len(results)) diff --git a/src/gaia/ui/server.py b/src/gaia/ui/server.py index 614907c80..c1b87c4d5 100644 --- a/src/gaia/ui/server.py +++ b/src/gaia/ui/server.py @@ -58,6 +58,7 @@ from .routers import hub as hub_router_mod from .routers import mcp as mcp_router_mod from .routers import memory as memory_router_mod +from .routers import schedules as schedules_router_mod from .routers import sessions as sessions_router_mod from .routers import system as system_router_mod from .routers import tunnel as tunnel_router_mod @@ -509,6 +510,7 @@ async def _global_exception_handler(request: Request, exc: Exception): app.include_router(tunnel_router_mod.router) app.include_router(goals_router_mod.router) app.include_router(memory_router_mod.router) + app.include_router(schedules_router_mod.router) app.include_router(mcp_router_mod.router) # Issue #915 — OAuth connections (Settings page + agent grants). app.include_router(connectors_router_mod.router) diff --git a/tests/unit/test_scheduler_api.py b/tests/unit/test_scheduler_api.py new file mode 100644 index 000000000..11c93931d --- /dev/null +++ b/tests/unit/test_scheduler_api.py @@ -0,0 +1,258 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""REST API tests for the GAIA Scheduler endpoints (M5: Scheduled Autonomy). + +Tests the /api/schedules/* endpoints using FastAPI TestClient. +""" + +import asyncio + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gaia.ui.database import ChatDatabase +from gaia.ui.routers.schedules import get_scheduler, router +from gaia.ui.scheduler import Scheduler + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +async def fake_executor(prompt: str) -> str: + """Minimal executor for API tests.""" + return f"ran: {prompt}" + + +@pytest.fixture +def app_with_scheduler(): + """Create a FastAPI app with scheduler for testing.""" + db = ChatDatabase(":memory:") + scheduler = Scheduler(db=db, executor=fake_executor) + + # Run scheduler start in event loop + loop = asyncio.new_event_loop() + loop.run_until_complete(scheduler.start()) + + app = FastAPI() + app.include_router(router) + app.state.scheduler = scheduler + + # Override dependency + app.dependency_overrides[get_scheduler] = lambda: scheduler + + yield app, scheduler, db + + # Cleanup + loop.run_until_complete(scheduler.shutdown()) + loop.close() + db.close() + + +@pytest.fixture +def client(app_with_scheduler): + """FastAPI test client.""" + app, _, _ = app_with_scheduler + return TestClient(app) + + +# ── POST /api/schedules tests ──────────────────────────────────────────────── + + +class TestCreateSchedule: + """Test POST /api/schedules.""" + + def test_create_schedule(self, client): + resp = client.post( + "/api/schedules", + json={ + "name": "daily-report", + "interval": "every 24h", + "prompt": "Summarize today", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "daily-report" + assert data["interval_seconds"] == 86400 + assert data["prompt"] == "Summarize today" + assert data["status"] == "active" + + def test_create_schedule_30m(self, client): + resp = client.post( + "/api/schedules", + json={ + "name": "check-emails", + "interval": "every 30m", + "prompt": "Check mail", + }, + ) + assert resp.status_code == 200 + assert resp.json()["interval_seconds"] == 1800 + + def test_create_duplicate(self, client): + client.post( + "/api/schedules", + json={"name": "dup", "interval": "every 1h", "prompt": "First"}, + ) + resp = client.post( + "/api/schedules", + json={"name": "dup", "interval": "every 2h", "prompt": "Second"}, + ) + assert resp.status_code == 400 + assert "already exists" in resp.json()["detail"] + + def test_create_invalid_interval(self, client): + resp = client.post( + "/api/schedules", + json={"name": "bad", "interval": "whenever", "prompt": "Prompt"}, + ) + assert resp.status_code == 400 + assert "Cannot parse interval" in resp.json()["detail"] + + def test_create_missing_fields(self, client): + resp = client.post("/api/schedules", json={"name": "incomplete"}) + assert resp.status_code == 422 # Pydantic validation + + +# ── GET /api/schedules tests ───────────────────────────────────────────────── + + +class TestListSchedules: + """Test GET /api/schedules.""" + + def test_list_empty(self, client): + resp = client.get("/api/schedules") + assert resp.status_code == 200 + data = resp.json() + assert data["schedules"] == [] + assert data["total"] == 0 + + def test_list_with_tasks(self, client): + client.post( + "/api/schedules", + json={"name": "task-a", "interval": "every 1h", "prompt": "A"}, + ) + client.post( + "/api/schedules", + json={"name": "task-b", "interval": "every 2h", "prompt": "B"}, + ) + resp = client.get("/api/schedules") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + names = {s["name"] for s in data["schedules"]} + assert names == {"task-a", "task-b"} + + +# ── GET /api/schedules/{name} tests ────────────────────────────────────────── + + +class TestGetSchedule: + """Test GET /api/schedules/{name}.""" + + def test_get_existing(self, client): + client.post( + "/api/schedules", + json={"name": "my-sched", "interval": "every 6h", "prompt": "Do it"}, + ) + resp = client.get("/api/schedules/my-sched") + assert resp.status_code == 200 + assert resp.json()["name"] == "my-sched" + + def test_get_not_found(self, client): + resp = client.get("/api/schedules/nonexistent") + assert resp.status_code == 404 + + +# ── PUT /api/schedules/{name} tests ────────────────────────────────────────── + + +class TestUpdateSchedule: + """Test PUT /api/schedules/{name}.""" + + def test_pause_schedule(self, client): + client.post( + "/api/schedules", + json={"name": "pausable", "interval": "every 1h", "prompt": "P"}, + ) + resp = client.put("/api/schedules/pausable", json={"status": "paused"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "paused" + + def test_resume_schedule(self, client): + client.post( + "/api/schedules", + json={"name": "resumable", "interval": "every 1h", "prompt": "R"}, + ) + client.put("/api/schedules/resumable", json={"status": "paused"}) + resp = client.put("/api/schedules/resumable", json={"status": "active"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "active" + + def test_cancel_schedule(self, client): + client.post( + "/api/schedules", + json={"name": "cancellable", "interval": "every 1h", "prompt": "C"}, + ) + resp = client.put("/api/schedules/cancellable", json={"status": "cancelled"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "cancelled" + + def test_update_not_found(self, client): + resp = client.put("/api/schedules/ghost", json={"status": "paused"}) + assert resp.status_code == 404 + + def test_invalid_status(self, client): + client.post( + "/api/schedules", + json={"name": "inv", "interval": "every 1h", "prompt": "I"}, + ) + resp = client.put("/api/schedules/inv", json={"status": "invalid"}) + assert resp.status_code == 400 + + +# ── DELETE /api/schedules/{name} tests ─────────────────────────────────────── + + +class TestDeleteSchedule: + """Test DELETE /api/schedules/{name}.""" + + def test_delete_existing(self, client): + client.post( + "/api/schedules", + json={"name": "del-me", "interval": "every 1h", "prompt": "D"}, + ) + resp = client.delete("/api/schedules/del-me") + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + + # Verify it's gone + resp = client.get("/api/schedules/del-me") + assert resp.status_code == 404 + + def test_delete_not_found(self, client): + resp = client.delete("/api/schedules/nonexistent") + assert resp.status_code == 404 + + +# ── GET /api/schedules/{name}/results tests ────────────────────────────────── + + +class TestScheduleResults: + """Test GET /api/schedules/{name}/results.""" + + def test_results_empty(self, client): + client.post( + "/api/schedules", + json={"name": "no-results", "interval": "every 1h", "prompt": "N"}, + ) + resp = client.get("/api/schedules/no-results/results") + assert resp.status_code == 200 + data = resp.json() + assert data["results"] == [] + assert data["total"] == 0 + + def test_results_not_found(self, client): + resp = client.get("/api/schedules/nonexistent/results") + assert resp.status_code == 404 From 2f0604bd970dedb1e105c2116a9927902af1cff7 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 9 Jun 2026 23:50:45 -0700 Subject: [PATCH 4/9] feat(ui): wire scheduler into server lifespan with tunnel gate Scheduled runs are suspended while a public tunnel is active (same posture as AgentLoop) unless GAIA_AUTONOMOUS_ALLOW_TUNNEL is set. Per-run timeout configurable via GAIA_SCHEDULE_TIMEOUT (default 300s). --- src/gaia/ui/server.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/gaia/ui/server.py b/src/gaia/ui/server.py index c1b87c4d5..ad35850a1 100644 --- a/src/gaia/ui/server.py +++ b/src/gaia/ui/server.py @@ -338,6 +338,54 @@ def _load_model(): await agent_loop.start(db=db, app_state=app.state) logger.info("AgentLoop started") + # ── Task scheduler (user-defined recurring tasks) ──────────────── + # Salvaged from #517. Scheduled tasks are explicit user automations + # (cron analogy), so they run standalone rather than through the + # GoalStore approval workflow — but they share AgentLoop's tunnel + # gate (no background runs while a public tunnel is active). + from gaia.ui.scheduler import Scheduler + + _sched_timeout = float(os.environ.get("GAIA_SCHEDULE_TIMEOUT", "300")) + + async def _schedule_executor(prompt: str) -> str: + """Execute a scheduled prompt through a fresh ChatAgent. + + Constructs the agent inline rather than via the chat router's + session cache: `_get_cached_agent` is a lookup keyed to live + interactive sessions (SSE/doc plumbing); background runs are + fresh-context by design. + """ + tunnel = getattr(app.state, "tunnel", None) + allow_tunnel = os.environ.get( + "GAIA_AUTONOMOUS_ALLOW_TUNNEL", "" + ).lower() in ("1", "true", "yes") + if tunnel and tunnel.active and not allow_tunnel: + raise RuntimeError( + "Scheduled run suspended: public tunnel is active. " + "Stop the tunnel or set GAIA_AUTONOMOUS_ALLOW_TUNNEL=1." + ) + + def _run() -> str: + from gaia.agents.chat.agent import ChatAgent, ChatAgentConfig + + config = ChatAgentConfig(max_steps=5, silent_mode=True, debug=False) + agent = ChatAgent(config) + result = agent.process_query(prompt) + if isinstance(result, dict): + val = result.get("result") + return val if val is not None else result.get("answer", "") + return str(result) if result else "" + + loop = asyncio.get_running_loop() + return await asyncio.wait_for( + loop.run_in_executor(None, _run), timeout=_sched_timeout + ) + + scheduler = Scheduler(db=db, executor=_schedule_executor) + app.state.scheduler = scheduler + await scheduler.start() + logger.info("Task scheduler started (timeout=%ss)", _sched_timeout) + # Start document file monitor for auto re-indexing monitor = DocumentMonitor( db=db, @@ -413,6 +461,8 @@ def _load_model(): yield # Shutdown + await scheduler.shutdown() + logger.info("Task scheduler stopped") await agent_loop.stop() logger.info("AgentLoop stopped") await queue.shutdown() From 8dca5386393cbb9bebd72bafa9bf6dfe5908285c Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Tue, 9 Jun 2026 23:55:20 -0700 Subject: [PATCH 5/9] feat(webui): ScheduleManager panel for recurring tasks Clock button in the sidebar opens the schedule panel: natural-language schedule input with live parse preview, create/pause/resume/delete with confirm, run counts and next-run countdown. --- src/gaia/apps/webui/src/App.tsx | 7 +- .../webui/src/components/ScheduleManager.css | 405 ++++++++++++++++++ .../webui/src/components/ScheduleManager.tsx | 401 +++++++++++++++++ .../apps/webui/src/components/Sidebar.tsx | 7 +- src/gaia/apps/webui/src/services/api.ts | 32 +- src/gaia/apps/webui/src/stores/chatStore.ts | 9 +- .../apps/webui/src/stores/scheduleStore.ts | 120 ++++++ src/gaia/apps/webui/src/types/index.ts | 39 ++ 8 files changed, 1014 insertions(+), 6 deletions(-) create mode 100644 src/gaia/apps/webui/src/components/ScheduleManager.css create mode 100644 src/gaia/apps/webui/src/components/ScheduleManager.tsx create mode 100644 src/gaia/apps/webui/src/stores/scheduleStore.ts diff --git a/src/gaia/apps/webui/src/App.tsx b/src/gaia/apps/webui/src/App.tsx index 2e0b5b0f8..eb9d7c2d7 100644 --- a/src/gaia/apps/webui/src/App.tsx +++ b/src/gaia/apps/webui/src/App.tsx @@ -9,6 +9,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'; import { DocumentLibrary } from './components/DocumentLibrary'; import { FileBrowser } from './components/FileBrowser'; import { MemoryDashboard } from './components/MemoryDashboard'; +import { ScheduleManager } from './components/ScheduleManager'; import { SettingsPage } from './components/SettingsPage'; import { MobileAccessModal } from './components/MobileAccessModal'; import { ConnectionBanner } from './components/ConnectionBanner'; @@ -69,6 +70,8 @@ function App() { setShowSettings, showMemoryDashboard, setShowMemoryDashboard, + showSchedules, + setShowSchedules, sidebarOpen, toggleSidebar, setSidebarOpen, @@ -531,7 +534,7 @@ function App() { { setCurrentSession(null); setShowSettings(false); setShowMemoryDashboard(false); window.history.replaceState(null, '', window.location.pathname); }} + onHome={() => { setCurrentSession(null); setShowSettings(false); setShowMemoryDashboard(false); setShowSchedules(false); window.history.replaceState(null, '', window.location.pathname); }} tunnelActive={tunnelActive} tunnelLoading={tunnelLoading} onMobileToggle={handleMobileToggle} @@ -542,6 +545,8 @@ function App() { ) : showMemoryDashboard ? ( + ) : showSchedules ? ( + ) : ( <> {/* Connection / LLM status banner */} diff --git a/src/gaia/apps/webui/src/components/ScheduleManager.css b/src/gaia/apps/webui/src/components/ScheduleManager.css new file mode 100644 index 000000000..5b0075f47 --- /dev/null +++ b/src/gaia/apps/webui/src/components/ScheduleManager.css @@ -0,0 +1,405 @@ +/* Schedule Manager */ +.schedule-modal { width: 640px; max-height: 80vh; display: flex; flex-direction: column; } + +.schedule-modal .modal-body { overflow-y: auto; flex: 1; } + +/* Create form */ +.schedule-create-form { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.schedule-form-row { + display: flex; + gap: 8px; +} + +.schedule-form-row input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); +} + +.schedule-form-row input:focus { + outline: none; + border-color: var(--amd-red); +} + +.schedule-form-row input::placeholder { + color: var(--text-muted); +} + +.schedule-prompt-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); + resize: vertical; + min-height: 60px; +} + +.schedule-prompt-input:focus { + outline: none; + border-color: var(--amd-red); +} + +.schedule-prompt-input::placeholder { + color: var(--text-muted); +} + +.schedule-create-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.schedule-form-error { + font-size: 12px; + color: var(--amd-red); + margin-top: -4px; +} + +/* Schedule list */ +.schedule-section { + margin-bottom: 20px; +} + +.schedule-section:last-child { + margin-bottom: 0; +} + +.schedule-section h4 { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.schedule-empty { + font-size: 13px; + color: var(--text-muted); + text-align: center; + padding: 24px 12px; +} + +.schedule-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Individual schedule card */ +.schedule-card { + display: flex; + flex-direction: column; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + border: 1px solid var(--border-light); + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.schedule-card:hover { + background: var(--bg-hover); +} + +.schedule-card.selected { + border-color: var(--amd-red); + background: var(--bg-hover); +} + +.schedule-card.just-ran { + animation: schedule-flash 1.5s ease-out; +} + +@keyframes schedule-flash { + 0% { border-color: var(--accent-green); box-shadow: 0 0 8px rgba(78, 201, 50, 0.3); } + 100% { border-color: var(--border-light); box-shadow: none; } +} + +.schedule-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.schedule-card-name { + font-size: 13px; + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-card-status { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +.schedule-card-status.active { + color: #22c55e; + background: rgba(34, 197, 94, 0.1); +} + +.schedule-card-status.paused { + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.schedule-card-status.cancelled { + color: var(--text-muted); + background: var(--bg-tertiary); +} + +.schedule-card-meta { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.schedule-card-prompt { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-card-actions { + display: flex; + gap: 4px; + margin-top: 8px; +} + +.schedule-card-actions .btn-sm { + font-size: 11px; + padding: 3px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.schedule-card-actions .btn-sm:hover { + background: var(--bg-active); +} + +.schedule-card-actions .btn-sm.chat { + color: #3b82f6; + border-color: #3b82f6; +} + +.schedule-card-actions .btn-sm.chat:hover { + background: rgba(59, 130, 246, 0.1); +} + +.schedule-card-actions .btn-sm.danger { + color: var(--amd-red); + border-color: var(--amd-red); +} + +.schedule-card-actions .btn-sm.danger:hover { + background: rgba(237, 28, 36, 0.1); +} + +/* Results panel */ +.schedule-results { + margin-top: 16px; + border-top: 1px solid var(--border); + padding-top: 14px; +} + +.schedule-results h4 { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.schedule-results-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; +} + +.schedule-result-item { + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + font-size: 12px; +} + +.schedule-result-time { + color: var(--text-muted); + margin-bottom: 4px; + font-family: var(--font-mono); + font-size: 11px; +} + +.schedule-result-content { + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + max-height: 60px; + overflow-y: auto; +} + +.schedule-result-error { + color: var(--amd-red); +} + +.schedule-results-empty { + font-size: 12px; + color: var(--text-muted); + text-align: center; + padding: 12px; +} + +/* NL input */ +.schedule-nl-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); + resize: vertical; + min-height: 50px; +} + +.schedule-nl-input:focus { + outline: none; + border-color: var(--amd-red); +} + +.schedule-nl-input::placeholder { + color: var(--text-muted); +} + +/* Parsed preview */ +.schedule-parsed-preview { + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); + background: var(--bg-tertiary); + font-size: 12px; +} + +.schedule-parsed-preview.valid { + border-color: #22c55e; + background: rgba(34, 197, 94, 0.05); +} + +.schedule-parsed-preview.invalid { + border-color: var(--amd-red); + background: rgba(237, 28, 36, 0.05); +} + +.schedule-parsed-loading { + color: var(--text-muted); + font-style: italic; +} + +.schedule-parsed-description { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.schedule-parsed-next { + color: var(--text-secondary); + font-size: 11px; + margin-bottom: 6px; +} + +.schedule-parsed-details { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.schedule-parsed-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + border: 1px solid var(--border); + font-size: 11px; + color: var(--text-secondary); +} + +/* Missing field highlight */ +.schedule-field-missing { + border-color: #f59e0b !important; + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3); +} + +.btn-primary { + padding: 6px 16px; + border-radius: var(--radius-sm); + border: none; + background: var(--amd-red); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.btn-primary:hover { + background: var(--amd-red-dark); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + padding: 6px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: background var(--duration-fast) var(--ease); +} + +.btn-secondary:hover { + background: var(--bg-active); +} diff --git a/src/gaia/apps/webui/src/components/ScheduleManager.tsx b/src/gaia/apps/webui/src/components/ScheduleManager.tsx new file mode 100644 index 000000000..6cf64f1cf --- /dev/null +++ b/src/gaia/apps/webui/src/components/ScheduleManager.tsx @@ -0,0 +1,401 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { X, Plus, Clock, Play, Pause, Trash2, ChevronDown, ChevronUp, MessageSquare } from 'lucide-react'; +import { useChatStore } from '../stores/chatStore'; +import { useScheduleStore } from '../stores/scheduleStore'; +import { log } from '../utils/logger'; +import type { Schedule, ScheduleResult, ParsedSchedule } from '../types'; +import { parseScheduleInput } from '../services/api'; +import './ScheduleManager.css'; + +/** Format an interval in seconds to a human-readable string. */ +function formatInterval(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h`; + return `${Math.round(seconds / 86400)}d`; +} + +/** Format an ISO date string to a relative or short time. */ +function formatTime(iso: string | null, now?: Date): string { + if (!iso) return 'never'; + const d = new Date(iso); + const current = now || new Date(); + const diffMs = current.getTime() - d.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + + if (diffSecs < 0) { + // Future time + const futureSecs = Math.abs(diffSecs); + if (futureSecs < 60) return `in ${futureSecs}s`; + const futureMins = Math.floor(futureSecs / 60); + if (futureMins < 60) return `in ${futureMins}m`; + const hrs = Math.floor(futureMins / 60); + if (hrs < 24) return `in ${hrs}h`; + return `in ${Math.floor(hrs / 24)}d`; + } + if (diffSecs < 60) return 'just now'; + const mins = Math.floor(diffSecs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +/** Returns true if the ISO timestamp is less than 60 seconds in the future. */ +function isCountdownRange(iso: string | null): boolean { + if (!iso) return false; + const diffMs = new Date(iso).getTime() - Date.now(); + return diffMs > 0 && diffMs < 60000; +} + +/** Format "HH:MM" to "H:MM AM/PM". */ +function formatTimeOfDay(time: string): string { + const [h, m] = time.split(':').map(Number); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + return `${h12}:${m.toString().padStart(2, '0')} ${ampm}`; +} + +/** Format hour number to "H AM/PM". */ +function formatHour(h: number): string { + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + return `${h12} ${ampm}`; +} + +/** Format days_of_week array to human string. */ +function formatDays(days: number[]): string { + const names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + if (days.length === 5 && !days.includes(5) && !days.includes(6)) return 'Weekdays'; + if (days.length === 2 && days.includes(5) && days.includes(6)) return 'Weekends'; + if (days.length === 7) return 'Every day'; + return days.map(d => names[d]).join(', '); +} + +function ScheduleCard({ schedule }: { schedule: Schedule }) { + const { selectedSchedule, selectSchedule, updateScheduleStatus, deleteSchedule, results, resultsLoading, loadSchedules, loadResults } = useScheduleStore(); + const { setCurrentSession, setShowSchedules } = useChatStore(); + const isSelected = selectedSchedule === schedule.name; + const [confirmDelete, setConfirmDelete] = useState(false); + + // Live countdown: tick every second when next_run_at is < 60s away + const [now, setNow] = useState(() => new Date()); + const tickRef = useRef | null>(null); + + useEffect(() => { + const needsTick = schedule.status === 'active' && isCountdownRange(schedule.next_run_at); + if (needsTick && !tickRef.current) { + tickRef.current = setInterval(() => { + setNow(new Date()); + // When countdown reaches 0, refresh schedule list to get updated run info + if (schedule.next_run_at && new Date(schedule.next_run_at).getTime() <= Date.now()) { + loadSchedules(); + } + }, 1000); + } else if (!needsTick && tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + return () => { + if (tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }; + }, [schedule.status, schedule.next_run_at, loadSchedules]); + + // Auto-refresh results when a new run completes (last_run_at changes) + const prevRunRef = useRef(schedule.last_run_at); + useEffect(() => { + if (isSelected && schedule.last_run_at !== prevRunRef.current) { + prevRunRef.current = schedule.last_run_at; + loadResults(schedule.name); + } + }, [isSelected, schedule.last_run_at, schedule.name, loadResults]); + + // Flash effect when a run just completed (within last 5 seconds) + const justRan = schedule.last_run_at && (Date.now() - new Date(schedule.last_run_at).getTime()) < 5000; + + const handleToggleStatus = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + const newStatus = schedule.status === 'active' ? 'paused' : 'active'; + log.ui.info(`Schedule "${schedule.name}": ${schedule.status} -> ${newStatus}`); + updateScheduleStatus(schedule.name, newStatus); + }, [schedule.name, schedule.status, updateScheduleStatus]); + + const handleDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirmDelete) { + setConfirmDelete(true); + setTimeout(() => setConfirmDelete(false), 3000); + return; + } + log.ui.info(`Deleting schedule "${schedule.name}"`); + deleteSchedule(schedule.name); + }, [schedule.name, confirmDelete, deleteSchedule]); + + const handleOpenChat = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (schedule.session_id) { + log.ui.info(`Opening chat session for schedule "${schedule.name}"`); + setCurrentSession(schedule.session_id); + setShowSchedules(false); + } + }, [schedule.session_id, schedule.name, setCurrentSession, setShowSchedules]); + + return ( +
selectSchedule(isSelected ? null : schedule.name)} + > +
+ {schedule.name} + {schedule.status} +
+
{schedule.prompt}
+
+ {formatInterval(schedule.interval_seconds)} + Runs: {schedule.run_count} + {schedule.error_count > 0 && Errors: {schedule.error_count}} + Last: {formatTime(schedule.last_run_at)} + Next: {formatTime(schedule.next_run_at, now)} +
+
+ {schedule.session_id && ( + + )} + + +
+ + {isSelected && ( +
+

Execution History

+ {resultsLoading ? ( +

Loading...

+ ) : results.length === 0 ? ( +

No executions yet

+ ) : ( +
+ {results.map((r) => ( +
+
{formatTime(r.executed_at)}
+ {r.error ? ( +
{r.error}
+ ) : ( +
{r.result || '(no output)'}
+ )} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +export function ScheduleManager() { + const { setShowSchedules } = useChatStore(); + const { schedules, loading, error, loadSchedules, createSchedule } = useScheduleStore(); + + // Create form state + const [showCreate, setShowCreate] = useState(false); + const [nlInput, setNlInput] = useState(''); + const [scheduleName, setScheduleName] = useState(''); + const [schedulePrompt, setSchedulePrompt] = useState(''); + const [parsed, setParsed] = useState(null); + const [parsing, setParsing] = useState(false); + const [creating, setCreating] = useState(false); + const [formError, setFormError] = useState(null); + + // Debounced parse of NL input + const parseTimerRef = useRef(null); + + useEffect(() => { + if (!nlInput.trim()) { + setParsed(null); + return; + } + if (parseTimerRef.current) clearTimeout(parseTimerRef.current); + parseTimerRef.current = window.setTimeout(async () => { + setParsing(true); + try { + const result = await parseScheduleInput(nlInput); + setParsed(result); + } catch { + setParsed(null); + } finally { + setParsing(false); + } + }, 400); + return () => { if (parseTimerRef.current) clearTimeout(parseTimerRef.current); }; + }, [nlInput]); + + // Load on mount + poll every 5s so we see updates when tasks fire + const pollRef = useRef(null); + + useEffect(() => { + log.ui.info('Schedule Manager opened'); + loadSchedules(); + pollRef.current = window.setInterval(() => loadSchedules(), 5000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [loadSchedules]); + + // Allow creation if all 3 fields are filled. parsed?.valid is a bonus + // (shows preview) but not a hard gate — the backend validates on create. + const canCreate = nlInput.trim() && scheduleName.trim() && schedulePrompt.trim() + && (parsed === null || parsed.valid); // block only if parse explicitly returned invalid + + const handleCreate = useCallback(async () => { + if (!canCreate) return; + setCreating(true); + setFormError(null); + try { + // Pass the raw NL input as interval - backend will re-parse it + await createSchedule(scheduleName.trim(), nlInput.trim(), schedulePrompt.trim()); + setNlInput(''); + setScheduleName(''); + setSchedulePrompt(''); + setParsed(null); + setShowCreate(false); + log.ui.info(`Schedule "${scheduleName}" created`); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to create schedule'; + setFormError(msg); + } finally { + setCreating(false); + } + }, [canCreate, scheduleName, nlInput, schedulePrompt, createSchedule]); + + return ( +
setShowSchedules(false)} role="dialog" aria-modal="true" aria-label="Schedule Manager"> +
e.stopPropagation()}> +
+

Scheduled Tasks

+ +
+ +
+ {/* Create new schedule */} +
+ {!showCreate ? ( + + ) : ( +
+ {/* NL input */} +