Files
odysseus/routes/cookbook_schedule_routes.py
T
pewdiepie-archdaemon b98ee04e2f Cookbook scheduler: reuse the standard calendar event card + auto-create Cookbook calendar
Drop the custom Schedule modal in favor of opening the calendar's existing event-creation form pre-filled with the model's name + cookbook YAML in the description. The user lands in the same event editor they already know from regular calendar use, just pointed at the auto-created "Cookbook" calendar.

Backend:
  - POST /api/cookbook/schedule/ensure-calendar — idempotent: creates a calendar named "Cookbook" if one doesn't exist for the current user, saves its href into cookbook_schedule_calendar_href, flips cookbook_scheduler_enabled on. Verifies the saved href against /api/calendar/calendars on every call so a manually-deleted calendar self-heals.

Frontend:
  - calendar.js: expose window.cookbookOpenScheduleForm(draft) which opens the calendar modal (if not open), calls _showEventForm, then pre-fills summary / description / rrule / calendar dropdown. Force-expands the "Add details" section so the user can see which calendar it's heading into.
  - cookbookSchedule.js: Schedule-button click now calls ensure-calendar, builds the cookbook: YAML block, and routes to window.cookbookOpenScheduleForm instead of openModal(). The legacy custom modal stays as a fallback for the case where calendar.js hasn't loaded.

UX tweak:
  - cookbookServe.js: replace the standalone "Schedule…" text button with a small icon-only button (clock SVG) glued to the right edge of Launch. The pair forms one visual unit — Launch on the left, schedule-now on the right — sharing a thin divider. CSS handles the rounded corners + divider.
2026-06-05 02:52:07 +09:00

267 lines
12 KiB
Python

"""Cookbook schedule routes — turns the Cookbook \"Schedule\" modal into
calendar events on the designated schedule calendar, and exposes a
diagnostic /upcoming endpoint for the UI.
All routes live under /api/cookbook/schedule/* so the whole file can be
removed by deleting one router-registration line in app.py. The setup
function is a no-op when `cookbook_scheduler_enabled` is False.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, HTTPException, Request
from core.middleware import require_admin
logger = logging.getLogger(__name__)
_DAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
def setup_cookbook_schedule_routes() -> APIRouter:
router = APIRouter(prefix="/api/cookbook/schedule", tags=["cookbook-schedule"])
@router.get("/upcoming")
async def upcoming(request: Request, hours: int = 24):
"""Next N hours of scheduled events with reconciler status.
Drives the "what's running, what's queued" badges in the
Cookbook UI. Cheap read — no SSH, just calendar + state file.
"""
require_admin(request)
from src.settings import get_setting
if not get_setting("cookbook_scheduler_enabled", False):
return {"enabled": False, "events": []}
calendar_href = get_setting("cookbook_schedule_calendar_href", "") or ""
if not calendar_href:
return {"enabled": True, "calendar_href": "", "events": []}
hours = max(1, min(int(hours or 24), 24 * 14))
now = datetime.now(timezone.utc)
end = now + timedelta(hours=hours)
from src.cookbook_scheduler import _fetch_calendar_events, _read_state
events = await _fetch_calendar_events(calendar_href, now - timedelta(minutes=5), end)
state = _read_state()
tracked = (state.get("scheduler") or {}).get("events") or {}
out: List[Dict[str, Any]] = []
for ev in events:
uid = ev.get("uid") or ev.get("id") or ""
if not uid:
continue
t = tracked.get(uid) or {}
out.append({
"uid": uid,
"title": ev.get("summary") or "",
"start": ev.get("dtstart") or ev.get("start"),
"end": ev.get("dtend") or ev.get("end"),
"status": t.get("status") or "scheduled",
"reason": t.get("reason") or "",
"session_id": t.get("session_id") or "",
})
return {"enabled": True, "calendar_href": calendar_href, "events": out}
@router.post("/from-cookbook")
async def schedule_from_cookbook(request: Request, body: Dict[str, Any] = Body(default_factory=dict)):
"""Create one or more calendar events from the Cookbook Schedule modal.
Body shape:
{
"model": "Qwen3.5-397B-A17B-AWQ", # display title
"preset": "Qwen3.5-397B-A17B-AWQ", # optional, matched to saved preset
"repo_id": "...", # optional, for non-preset launches
"cmd": "vllm serve ...", # optional
"host": "pewds@192.168.1.12", # optional
"port": 8003, # optional
"slots": [
{"start": "09:00", "end": "17:00"}, # one or more time windows per day
{"start": "21:00", "end": "23:30"}
],
"days": ["MO","TU","WE","TH","FR"], # weekdays this repeats
"until": "2026-12-31", # optional end date, else forever
"start_date": "2026-06-05" # optional first day, else today
}
Creates one calendar event per slot (so split-shift schedules
are visible as separate blocks). All events share the same
RRULE so they can be edited together by changing one.
"""
require_admin(request)
from src.settings import get_setting
if not get_setting("cookbook_scheduler_enabled", False):
raise HTTPException(400, "Cookbook scheduler is not enabled in Settings.")
calendar_href = get_setting("cookbook_schedule_calendar_href", "") or ""
if not calendar_href:
raise HTTPException(400, "No Cookbook schedule calendar is configured in Settings.")
title = (body.get("model") or body.get("title") or "").strip()
if not title:
raise HTTPException(400, "model (title) is required")
slots = body.get("slots") or []
if not isinstance(slots, list) or not slots:
raise HTTPException(400, "at least one time slot is required")
for s in slots:
if not isinstance(s, dict):
raise HTTPException(400, "slot must be an object")
if not _HHMM_RE.match(str(s.get("start") or "")):
raise HTTPException(400, f"slot.start must be HH:MM, got {s.get('start')!r}")
if not _HHMM_RE.match(str(s.get("end") or "")):
raise HTTPException(400, f"slot.end must be HH:MM, got {s.get('end')!r}")
days = [d for d in (body.get("days") or []) if d in _DAYS]
if not days:
# Default to every day if the user didn't pick.
days = list(_DAYS)
# Compose the cookbook: YAML block dropped into event DESCRIPTION
# so the reconciler knows how to launch.
yaml_lines = ["cookbook:"]
for k in ("preset", "repo_id", "cmd", "host", "port"):
v = body.get(k)
if v:
yaml_lines.append(f" {k}: {v}")
if len(yaml_lines) == 1:
# Fall back: the title alone is the preset name. Reconciler
# will preset-match against saved presets at launch time.
yaml_lines.append(f" preset: {title}")
description = "\n".join(yaml_lines)
# First-occurrence date defaults to today (UTC) so the schedule
# applies starting now. RRULE-BYDAY handles day filtering.
start_date = body.get("start_date")
if start_date:
try:
d0 = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(400, "start_date must be YYYY-MM-DD")
else:
d0 = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
until = (body.get("until") or "").strip()
until_clause = ""
if until:
try:
u = datetime.strptime(until, "%Y-%m-%d")
until_clause = f";UNTIL={u.strftime('%Y%m%dT235959Z')}"
except ValueError:
raise HTTPException(400, "until must be YYYY-MM-DD")
rrule = f"FREQ=WEEKLY;BYDAY={','.join(days)}{until_clause}"
# Create one event per slot. Call /api/calendar/events directly
# so we don't reinvent CalDAV plumbing.
import httpx
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
created: List[str] = []
for slot in slots:
sh, sm = [int(x) for x in str(slot["start"]).split(":")]
eh, em = [int(x) for x in str(slot["end"]).split(":")]
dtstart = d0.replace(hour=sh, minute=sm)
dtend = d0.replace(hour=eh, minute=em)
if dtend <= dtstart:
# Overnight: schedule the end on the next day.
dtend = dtend + timedelta(days=1)
ev_body = {
"summary": title,
"dtstart": dtstart.isoformat(),
"dtend": dtend.isoformat(),
"all_day": False,
"description": description,
"calendar_href": calendar_href,
"rrule": rrule,
"color": "#3b82f6",
}
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(
"http://localhost:7000/api/calendar/events",
json=ev_body, headers=headers,
)
if r.status_code >= 400:
logger.warning(f"schedule: calendar event create failed: {r.status_code} {r.text[:200]}")
continue
data = r.json()
except Exception as e:
logger.warning(f"schedule: calendar event create errored: {e}")
continue
uid = data.get("uid") or data.get("id") or ""
if uid:
created.append(uid)
if not created:
raise HTTPException(500, "Failed to create any calendar events for this schedule")
return {"ok": True, "created": created, "slots": len(slots), "rrule": rrule}
@router.post("/reconcile-now")
async def reconcile_now(request: Request):
"""Manual kick of the reconciler. Useful for testing + the
\"Run now\" button in the Cookbook UI."""
require_admin(request)
from src.cookbook_scheduler import _reconcile_once
return await _reconcile_once()
@router.post("/ensure-calendar")
async def ensure_calendar(request: Request):
"""Ensure a calendar named \"Cookbook\" exists for the current user
and is registered as the scheduler calendar in settings. Idempotent.
Used by the Schedule button so the user never has to pick: the
first click creates the calendar and wires it up; subsequent
clicks return the existing href.
"""
require_admin(request)
from src.settings import get_setting, _save_settings, _load_settings
existing = get_setting("cookbook_schedule_calendar_href", "") or ""
# Verify the saved href still exists in /api/calendar/calendars
# (the user might have deleted the calendar manually) by hitting
# the list endpoint loopback.
import httpx
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
live: list = []
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get("http://localhost:7000/api/calendar/calendars", headers=headers)
live = (r.json() or {}).get("calendars", []) if r.status_code < 400 else []
except Exception:
live = []
if existing and any(c.get("href") == existing for c in live):
match = next(c for c in live if c.get("href") == existing)
return {"ok": True, "href": existing, "name": match.get("name"), "created": False}
# No usable cookbook calendar — see if one named "Cookbook" already
# exists (perhaps from a previous setup), else create a fresh one.
match = next((c for c in live if (c.get("name") or "").lower() == "cookbook"), None)
if match:
href = match.get("href")
else:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.post(
"http://localhost:7000/api/calendar/calendars",
params={"name": "Cookbook", "color": "#3b82f6"},
headers=headers,
)
data = r.json() if r.content else {}
except Exception as exc:
raise HTTPException(500, f"create calendar failed: {exc}")
if not data.get("ok"):
raise HTTPException(500, f"create calendar failed: {data}")
href = data.get("id") or ""
if not href:
raise HTTPException(500, "no href returned from calendar create")
# Persist into settings (bypasses DEFAULT_SETTINGS guard by using
# _load/_save directly since we know this key is whitelisted).
s = _load_settings()
s["cookbook_schedule_calendar_href"] = href
if not s.get("cookbook_scheduler_enabled"):
s["cookbook_scheduler_enabled"] = True
_save_settings(s)
return {"ok": True, "href": href, "name": "Cookbook", "created": True}
return router