Revert calendar-based cookbook scheduler

Reverts b98ee04 + 4ed48ba + a19b6d2.

Calendar events turned out to be the wrong abstraction for scheduling model serve windows. Pivoting to the existing ScheduledTask infrastructure (cron / daily / weekly recurrence, next_run tracking, edit-from-Tasks-tab UI) in a follow-up commit. The ScheduledTask path:

  - reuses dispatch logic the rest of the app already understands
  - drops the calendar dependency entirely (no auto-created "Cookbook" calendar, no calendar.js hook)
  - shows up in the Tasks UI that already exists for everything else

What this revert removes:
  - src/cookbook_scheduler.py — calendar reconciler
  - routes/cookbook_schedule_routes.py — /api/cookbook/schedule/* endpoints
  - static/js/cookbookSchedule.js — Schedule modal / settings card
  - cookbook_scheduler_enabled + cookbook_schedule_calendar_href settings keys
  - The window.cookbookOpenScheduleForm hook in calendar.js
  - The Schedule button + paired-button CSS in cookbookServe.js + style.css
This commit is contained in:
pewdiepie-archdaemon
2026-06-05 06:57:21 +09:00
parent b98ee04e2f
commit a260e0abd4
9 changed files with 0 additions and 1367 deletions

View File

@@ -1,266 +0,0 @@
"""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