""" Schedules web UI — GET/POST /settings/crons/* Lets users view, add, toggle, and remove cron jobs without going through the AI. Cron data lives in home/{user}/persona/{persona}/CRONS.json. Scheduler registration mirrors what tools/cron.py does so changes take effect immediately. """ import html as _html import logging import secrets from datetime import datetime, timezone from pathlib import Path import jwt from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from auth_utils import COOKIE_NAME, decode_token from cron_runner import load_crons, save_crons, parse_schedule from persona import list_user_personas logger = logging.getLogger(__name__) router = APIRouter() _STATIC = Path(__file__).parent.parent / "static" _LAST_PERSONA_COOKIE = "cx_last_persona" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_session_user(request: Request) -> str | None: token = request.cookies.get(COOKIE_NAME) if not token: return None try: return decode_token(token) except jwt.InvalidTokenError: return None def _preferred_persona(request: Request, username: str) -> str: names = list_user_personas(username) if not names: return "" cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "") if cookie_val in names: return cookie_val return names[0] def _integrations_nav(username: str) -> str: from auth_utils import _read_auth role = _read_auth(username).get("role", "user") if role == "admin": return 'Integrations' return "" def _now() -> str: return datetime.now(timezone.utc).isoformat() def _short_id() -> str: return "c_" + secrets.token_urlsafe(6) def _scheduler_add(job: dict, sched_kwargs: dict) -> None: import asyncio try: import scheduler as sched_module from cron_runner import run_job s = sched_module.get_scheduler() if s and s.running: sched_id = f"{job['user']}:{job['persona']}:{job['id']}" s.add_job( lambda j=job: asyncio.ensure_future(run_job(j)), "cron", id=sched_id, replace_existing=True, **sched_kwargs, ) except Exception as e: logger.warning("scheduler_add failed: %s", e) def _scheduler_remove(job_id: str) -> None: try: import scheduler as sched_module s = sched_module.get_scheduler() if s and s.running: s.remove_job(job_id) except Exception: pass def _scheduler_pause(job_id: str) -> None: try: import scheduler as sched_module s = sched_module.get_scheduler() if s and s.running: s.pause_job(job_id) except Exception: pass def _scheduler_resume(job_id: str) -> None: try: import scheduler as sched_module s = sched_module.get_scheduler() if s and s.running: s.resume_job(job_id) except Exception: pass _TYPE_CLASS = { "remind": "badge-remind", "note": "badge-note", "message": "badge-message", "brief": "badge-brief", "task": "badge-task", } def _render_cron_list(username: str) -> str: personas = list_user_personas(username) if not personas: return '
No personas found.
' all_empty = True html_parts: list[str] = [] for persona in personas: crons = load_crons(username, persona) if not crons: continue all_empty = False rows = [] for c in crons: cid = _html.escape(c["id"]) label = _html.escape(c.get("label", "")) schedule = _html.escape(c.get("schedule", "")) job_type = _html.escape(c.get("type", "")) payload = _html.escape(c.get("payload", "")) enabled = c.get("enabled", True) last_run = (c.get("last_run") or "")[:10] or "never" pers_esc = _html.escape(persona) type_class = _TYPE_CLASS.get(c.get("type", ""), "badge-note") status_cls = "badge-enabled" if enabled else "badge-paused" status_txt = "enabled" if enabled else "paused" toggle_txt = "Pause" if enabled else "Resume" rows.append(f""" {label} {schedule} {job_type} {payload} {last_run} {status_txt}
Edit
""") html_parts.append(f"""

{_html.escape(persona)}

{"".join(rows)}
LabelScheduleType PayloadLast runStatus
""") if all_empty: return '
No schedules yet. Add one below.
' return "\n".join(html_parts) def _persona_options(username: str, selected: str = "") -> str: personas = list_user_personas(username) return "\n".join( f'' for p in personas ) _TYPE_OPTIONS = ("remind", "note", "message", "brief", "task") _TYPE_LABELS = { "remind": "remind — append to REMINDERS.md", "note": "note — append to SCRATCH.md", "message": "message — send payload as-is", "brief": "brief — LLM response, no tools", "task": "task — full orchestrator tool loop", } def _render_edit_form(job: dict, persona: str) -> str: cid = _html.escape(job["id"]) pers_esc = _html.escape(persona) label = _html.escape(job.get("label", "")) schedule = _html.escape(job.get("schedule", "")) payload = _html.escape(job.get("payload", "")) cur_type = job.get("type", "remind") type_opts = "\n".join( f'' for t in _TYPE_OPTIONS ) return f"""

Edit schedule

hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM · monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM

Cancel
""" def _render_page(username: str, back_persona: str = "", success: str = "", error: str = "", edit_html: str = "") -> str: html = (_STATIC / "crons.html").read_text() html = html.replace("{{ edit_html }}", edit_html) html = html.replace("{{ cron_list_html }}", _render_cron_list(username)) html = html.replace("{{ persona_options }}", _persona_options(username, back_persona)) html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") html = html.replace("{{ integrations_nav }}", _integrations_nav(username)) if success: html = html.replace("", f'

{_html.escape(success)}

') if error: html = html.replace("", f'

{_html.escape(error)}

') return html # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @router.get("/settings/crons", include_in_schema=False) async def crons_page(request: Request): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) return HTMLResponse(_render_page(username, back_persona)) @router.post("/settings/crons/add", include_in_schema=False) async def cron_add( request: Request, persona: str = Form(""), label: str = Form(""), schedule: str = Form(""), job_type: str = Form(""), payload: str = Form(""), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) label = label.strip() schedule = schedule.strip() payload = payload.strip() persona = persona.strip() _VALID_TYPES = ("remind", "note", "message", "brief", "task") if job_type not in _VALID_TYPES: return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}")) try: sched_kwargs = parse_schedule(schedule) except ValueError as e: return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}")) if not label: return HTMLResponse(_render_page(username, back_persona, error="Label is required.")) if not payload: return HTMLResponse(_render_page(username, back_persona, error="Payload is required.")) crons = load_crons(username, persona) job = { "id": _short_id(), "user": username, "persona": persona, "label": label, "schedule": schedule, "type": job_type, "payload": payload, "enabled": True, "created_at": _now(), "last_run": None, } crons.append(job) save_crons(crons, username, persona) _scheduler_add(job, sched_kwargs) logger.info("cron added via UI: %s %s [%s]", job["id"], schedule, job_type) return HTMLResponse(_render_page(username, back_persona, success=f"Schedule '{label}' added.")) @router.post("/settings/crons/toggle", include_in_schema=False) async def cron_toggle( request: Request, cron_id: str = Form(""), persona: str = Form(""), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) crons = load_crons(username, persona) for c in crons: if c["id"] == cron_id: c["enabled"] = not c.get("enabled", True) save_crons(crons, username, persona) sched_id = f"{username}:{persona}:{cron_id}" if c["enabled"]: _scheduler_resume(sched_id) action = "resumed" else: _scheduler_pause(sched_id) action = "paused" logger.info("cron %s %s via UI", cron_id, action) return HTMLResponse(_render_page(username, back_persona, success=f"Schedule {action}.")) return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}")) @router.post("/settings/crons/remove", include_in_schema=False) async def cron_remove( request: Request, cron_id: str = Form(""), persona: str = Form(""), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) crons = load_crons(username, persona) before = len(crons) crons = [c for c in crons if c["id"] != cron_id] if len(crons) == before: return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}")) save_crons(crons, username, persona) _scheduler_remove(f"{username}:{persona}:{cron_id}") logger.info("cron %s removed via UI", cron_id) return HTMLResponse(_render_page(username, back_persona, success="Schedule deleted.")) @router.get("/settings/crons/edit", include_in_schema=False) async def cron_edit_page(request: Request, cron_id: str = "", persona: str = ""): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) crons = load_crons(username, persona) job = next((c for c in crons if c["id"] == cron_id), None) if not job: return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}")) edit_html = _render_edit_form(job, persona) return HTMLResponse(_render_page(username, back_persona, edit_html=edit_html)) @router.post("/settings/crons/save", include_in_schema=False) async def cron_save( request: Request, cron_id: str = Form(""), persona: str = Form(""), label: str = Form(""), schedule: str = Form(""), job_type: str = Form(""), payload: str = Form(""), ): username = _get_session_user(request) if not username: return RedirectResponse("/login", status_code=302) back_persona = _preferred_persona(request, username) label = label.strip() schedule = schedule.strip() payload = payload.strip() if job_type not in _TYPE_OPTIONS: return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}")) if not label: return HTMLResponse(_render_page(username, back_persona, error="Label is required.")) if not payload: return HTMLResponse(_render_page(username, back_persona, error="Payload is required.")) try: sched_kwargs = parse_schedule(schedule) except ValueError as e: # Re-render with the edit form still open so the user can fix the schedule crons = load_crons(username, persona) job = next((c for c in crons if c["id"] == cron_id), None) edit_html = _render_edit_form(job, persona) if job else "" return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}", edit_html=edit_html)) crons = load_crons(username, persona) for c in crons: if c["id"] == cron_id: c["label"] = label c["schedule"] = schedule c["type"] = job_type c["payload"] = payload save_crons(crons, username, persona) # Replace the live scheduler job with the updated schedule sched_id = f"{username}:{persona}:{cron_id}" _scheduler_remove(sched_id) if c.get("enabled", True): _scheduler_add(c, sched_kwargs) logger.info("cron %s updated via UI [%s]", cron_id, schedule) return HTMLResponse(_render_page(username, back_persona, success=f"Schedule '{label}' updated.")) return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))