diff --git a/app.py b/app.py index f34f0ec..7a00722 100644 --- a/app.py +++ b/app.py @@ -640,14 +640,6 @@ app.include_router(setup_shell_routes()) from routes.cookbook_routes import setup_cookbook_routes app.include_router(setup_cookbook_routes()) -# Cookbook scheduler — calendar-driven serve windows. -# Feature-flagged on the `cookbook_scheduler_enabled` setting (default -# off); disabling the setting silences the reconciler and the API -# refuses requests. Delete this block + src/cookbook_scheduler.py + -# routes/cookbook_schedule_routes.py to remove the feature entirely. -from routes.cookbook_schedule_routes import setup_cookbook_schedule_routes -app.include_router(setup_cookbook_schedule_routes()) - # Hardware model fitting (cookbook "What Fits?" tab) from routes.hwfit_routes import setup_hwfit_routes app.include_router(setup_hwfit_routes()) @@ -1069,14 +1061,6 @@ async def _startup_event(): logger.warning(f"Nightly skill audit failed: {e}") _startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop())) - - # Cookbook scheduler reconcile loop. Internally checks the - # cookbook_scheduler_enabled setting on every tick, so leaving this - # task running with the feature disabled costs ~one settings lookup - # per minute. Remove this line to dispose of the feature. - from src.cookbook_scheduler import reconcile_loop as _cookbook_reconcile_loop - _startup_tasks.append(asyncio.create_task(_cookbook_reconcile_loop())) - logger.info("Application startup complete") async def _shutdown_event(): diff --git a/routes/cookbook_schedule_routes.py b/routes/cookbook_schedule_routes.py deleted file mode 100644 index a87b427..0000000 --- a/routes/cookbook_schedule_routes.py +++ /dev/null @@ -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 diff --git a/src/cookbook_scheduler.py b/src/cookbook_scheduler.py deleted file mode 100644 index f237343..0000000 --- a/src/cookbook_scheduler.py +++ /dev/null @@ -1,456 +0,0 @@ -"""Cookbook scheduler — calendar-driven model launches. - -Calendar events on a designated calendar (configurable via setting -`cookbook_schedule_calendar_href`) are interpreted as serve schedules. -The reconciler ticks every ~60s, reads events whose window contains -"now", and reconciles the running serves against them: - - - Event starts in window AND no matching serve running → launch via - existing /api/model/serve. If GPU is busy, mark event "skipped" - with reason. No retry. - - Event ends in window AND a scheduled serve is running → hard-kill. - - Pre-existing manual serve matching the event's model → adopt it - (mark as owned by the event so it gets stopped at window end). - -Everything in this module is gated by setting `cookbook_scheduler_enabled`. -Setting that to False fully disables the feature without touching code. - -Event description format (YAML-ish, single nested key): - cookbook: - preset: Qwen3.5-397B-A17B-AWQ # or repo_id + cmd + host - repo_id: deepseek-ai/DeepSeek-V4-Flash - cmd: vllm serve /mnt/HADES/models/... - host: pewds@192.168.1.12 - port: 8003 - -If only the title is given, the title is matched against saved preset -names (case-insensitive substring match). -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import re -import time -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import httpx - -logger = logging.getLogger(__name__) - - -# Schedule-owned tasks are tagged with this so we can tell them apart -# from manual launches when deciding whether to hard-kill at window end. -SCHEDULE_OWNER_KEY = "_scheduledBy" -COOKBOOK_BASE_URL = "http://localhost:7000" - - -def _internal_headers() -> Dict[str, str]: - """Match the in-process loopback auth path used by chat-agent tools.""" - from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN - return {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} - - -def _parse_event_yaml(description: str) -> Dict[str, Any]: - """Pull the `cookbook:` block out of an event description. - - Deliberately tolerant: we don't want a calendar-edit typo (a stray - `>`, a tab, etc.) to silently drop the event. Returns {} on any - error so the caller falls back to title-match against presets. - """ - if not isinstance(description, str) or "cookbook:" not in description: - return {} - try: - block_start = description.index("cookbook:") - block = description[block_start:].split("\n") - out: Dict[str, Any] = {} - for line in block[1:]: - if not line.startswith((" ", "\t")): - # First non-indented line ends the block. - if line.strip() == "" and not out: - continue - break - k, _, v = line.strip().partition(":") - v = v.strip().strip("'").strip('"') - if k and v: - out[k] = v - return out - except Exception as e: - logger.debug(f"event yaml parse failed (ignored): {e}") - return {} - - -def _now_utc() -> datetime: - return datetime.now(timezone.utc) - - -def _parse_iso(s: str) -> Optional[datetime]: - if not s: - return None - try: - # Accept both ISO with and without timezone; assume UTC if naive. - s2 = s.replace("Z", "+00:00") - dt = datetime.fromisoformat(s2) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - except Exception: - return None - - -async def _fetch_calendar_events(calendar_href: str, start: datetime, end: datetime) -> List[Dict[str, Any]]: - """List events on a single calendar in [start, end]. - - Reuses /api/calendar/events. RRULE expansion happens server-side so - we get concrete occurrences, not the master recurring event. - """ - headers = _internal_headers() - params = { - "start": start.isoformat(), - "end": end.isoformat(), - "calendar": calendar_href, - } - try: - async with httpx.AsyncClient(timeout=15) as client: - r = await client.get( - f"{COOKBOOK_BASE_URL}/api/calendar/events", - params=params, headers=headers, - ) - if r.status_code >= 400: - logger.debug(f"calendar/events returned {r.status_code}: {r.text[:200]}") - return [] - data = r.json() - return data.get("events", []) if isinstance(data, dict) else [] - except Exception as e: - logger.warning(f"reconciler: failed to fetch calendar events: {e}") - return [] - - -async def _gpus_busy(host: str) -> bool: - """Best-effort: are any GPUs on `host` already under non-trivial load? - - Used to honor "refuse to launch if GPUs busy" semantics. We don't - block on a vllm process that's currently loading our OWN target — - that's handled separately (idempotent registration). The check is - "is there a foreign process holding GPU memory". - """ - headers = _internal_headers() - try: - async with httpx.AsyncClient(timeout=10) as client: - params = {"host": host} if host else {} - r = await client.get( - f"{COOKBOOK_BASE_URL}/api/cookbook/gpus", - params=params, headers=headers, - ) - if r.status_code >= 400: - return False - data = r.json() or {} - except Exception: - return False - for gpu in data.get("gpus") or []: - used_mb = int(gpu.get("used_mb") or 0) - # 500 MB threshold: enough to exclude an idle display driver - # (usually <300 MB) but catch any real allocation. - if used_mb > 500: - return True - return False - - -def _resolve_event_payload(event: Dict[str, Any], presets: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Turn a calendar event into a serve payload (or None if unschedulable). - - Tries event description's `cookbook:` block first; falls back to a - case-insensitive preset-name match against the event title. - """ - parsed = _parse_event_yaml(event.get("description") or "") - if parsed.get("repo_id") or parsed.get("cmd"): - return { - "repo_id": parsed.get("repo_id") or parsed.get("model") or (event.get("summary") or ""), - "cmd": parsed.get("cmd") or "", - "remote_host": parsed.get("host") or parsed.get("remote_host") or "", - "port": parsed.get("port"), - } - # Title-based preset lookup. - title = (event.get("summary") or "").strip() - if not title: - return None - preset_name = parsed.get("preset") or title - lname = preset_name.lower() - chosen = next( - (p for p in presets if isinstance(p, dict) and (p.get("name") or "").lower() == lname), - None, - ) - if chosen is None: - chosen = next( - (p for p in presets if isinstance(p, dict) and lname in (p.get("name") or "").lower()), - None, - ) - if chosen is None: - return None - cmd = (chosen.get("cmd") or "").strip() - # Adopted presets have no usable cmd — they can't be relaunched - # from the scheduler. - if not cmd or cmd.startswith("(adopted"): - logger.info(f"scheduler: preset {preset_name!r} has no cmd; cannot schedule") - return None - return { - "repo_id": chosen.get("model") or chosen.get("modelId") or "", - "cmd": cmd, - "remote_host": chosen.get("host") or chosen.get("remoteHost") or "", - "port": chosen.get("port"), - } - - -def _state_path() -> Path: - return Path("/app/data/cookbook_state.json") - - -def _read_state() -> Dict[str, Any]: - p = _state_path() - if not p.exists(): - return {} - try: - return json.loads(p.read_text(encoding="utf-8")) - except Exception: - return {} - - -def _write_state(state: Dict[str, Any]) -> None: - try: - from core.atomic_io import atomic_write_json - atomic_write_json(_state_path(), state) - except Exception as e: - logger.warning(f"scheduler: state write failed: {e}") - - -async def _launch_serve(payload: Dict[str, Any], event_uid: str) -> Optional[str]: - """Hit /api/model/serve. Returns session_id on success, None on failure.""" - headers = _internal_headers() - body = {"repo_id": payload["repo_id"], "cmd": payload["cmd"]} - if payload.get("remote_host"): - body["remote_host"] = payload["remote_host"] - # Pull env/gpu/hf_token from the host's saved server entry, same as - # the chat agent's serve_model does. Without this, vllm can't find - # its venv binaries. - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{COOKBOOK_BASE_URL}/api/cookbook/state", headers=headers) - st = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - except Exception: - st = {} - env = (st.get("env") or {}) if isinstance(st, dict) else {} - servers = env.get("servers") or [] - target_host = payload.get("remote_host") or "" - srv = next( - (s for s in servers if isinstance(s, dict) - and (s.get("host") == target_host or s.get("name") == target_host)), - {}, - ) - if srv.get("env") in ("venv", "conda") and srv.get("envPath"): - body["env_prefix"] = f"source {srv['envPath']}/bin/activate" if srv["env"] == "venv" else f"conda activate {srv['envPath']}" - if srv.get("hfToken"): - body["hf_token"] = srv["hfToken"] - if srv.get("port"): - body["ssh_port"] = str(srv["port"]) - if srv.get("platform"): - body["platform"] = srv["platform"] - try: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post(f"{COOKBOOK_BASE_URL}/api/model/serve", json=body, headers=headers) - data = r.json() if r.content else {} - except Exception as e: - logger.warning(f"scheduler: launch failed for event {event_uid}: {e}") - return None - if not data.get("ok"): - err = data.get("error") or data.get("detail") or "unknown" - logger.warning(f"scheduler: launch rejected for event {event_uid}: {err}") - return None - return data.get("session_id") - - -async def _stop_serve(session_id: str, host: str) -> None: - headers = _internal_headers() - try: - async with httpx.AsyncClient(timeout=15) as client: - await client.post(f"{COOKBOOK_BASE_URL}/api/model/stop", - json={"session_id": session_id, "remote_host": host}, - headers=headers) - except Exception as e: - logger.warning(f"scheduler: stop failed for {session_id}: {e}") - - -def _mark_event_status(state: Dict[str, Any], event_uid: str, status: str, - reason: str = "", session_id: str = "") -> None: - """Track per-event reconciliation status in cookbook_state.scheduler. - - Schema: - state.scheduler.events = { - "": { - "status": "running" | "skipped" | "ended" | "failed", - "reason": "", - "session_id": "...", - "ts": , - }, - ... - } - """ - sched = state.setdefault("scheduler", {}) - events = sched.setdefault("events", {}) - events[event_uid] = { - "status": status, - "reason": reason, - "session_id": session_id, - "ts": int(time.time() * 1000), - } - - -async def _reconcile_once() -> Dict[str, Any]: - """One reconciliation pass. Returns a dict for diagnostics + UI. - - Idempotent: running this twice in a row with no event changes - should produce the same state without double-launching or - double-killing. - """ - from src.settings import get_setting - if not get_setting("cookbook_scheduler_enabled", False): - return {"skipped": "disabled"} - calendar_href = get_setting("cookbook_schedule_calendar_href", "") or "" - if not calendar_href: - return {"skipped": "no_calendar_configured"} - - now = _now_utc() - # Look ±90s around now so a 60s tick still picks up events that - # started 30s ago but haven't been reconciled. - window_start = now - timedelta(seconds=90) - window_end = now + timedelta(seconds=90) - events = await _fetch_calendar_events(calendar_href, window_start, window_end) - state = _read_state() - presets = state.get("presets") or [] - sched = state.get("scheduler") or {} - tracked = sched.get("events") or {} - - out: Dict[str, Any] = {"events": []} - state_dirty = False - - # Classify each event by where `now` falls relative to its window. - for ev in events: - uid = ev.get("uid") or ev.get("id") or "" - if not uid: - continue - ev_start = _parse_iso(ev.get("dtstart") or ev.get("start") or "") - ev_end = _parse_iso(ev.get("dtend") or ev.get("end") or "") - if ev_start is None or ev_end is None: - continue - in_window = ev_start <= now < ev_end - just_ended = (ev_end <= now) and (now - ev_end) < timedelta(seconds=90) - ev_status = (tracked.get(uid) or {}).get("status") - ev_session = (tracked.get(uid) or {}).get("session_id") - - if just_ended and ev_session and ev_status in {"running", "adopted"}: - # Window closed → hard-kill (per user choice). - payload = _resolve_event_payload(ev, presets) or {} - host = payload.get("remote_host") or "" - await _stop_serve(ev_session, host) - _mark_event_status(state, uid, "ended", session_id=ev_session) - state_dirty = True - out["events"].append({"uid": uid, "status": "ended", "session_id": ev_session}) - continue - - if not in_window: - continue - - # In window. Determine whether a serve already exists for this event. - if ev_status == "running" and ev_session: - out["events"].append({"uid": uid, "status": "running", "session_id": ev_session}) - continue - if ev_status == "skipped": - # User chose: no retry within the window. - out["events"].append({"uid": uid, "status": "skipped", - "reason": (tracked.get(uid) or {}).get("reason", "")}) - continue - - payload = _resolve_event_payload(ev, presets) - if payload is None: - _mark_event_status(state, uid, "failed", - reason="no preset or cmd resolvable from event") - state_dirty = True - out["events"].append({"uid": uid, "status": "failed", "reason": "no preset"}) - continue - - # Adoption pass: is a non-scheduled serve already running this model? - target_host = payload.get("remote_host") or "" - for t in state.get("tasks") or []: - if not isinstance(t, dict): - continue - if t.get("type") != "serve": - continue - if (t.get("status") or "").lower() not in {"running", "ready", "loading", "warming"}: - continue - if t.get("remoteHost") != target_host: - continue - t_model = (t.get("payload") or {}).get("repo_id") or t.get("name") or "" - if t_model.split("/")[-1] == (payload["repo_id"] or "").split("/")[-1]: - t[SCHEDULE_OWNER_KEY] = uid - _mark_event_status(state, uid, "adopted", - reason="pre-existing serve adopted", - session_id=t.get("sessionId") or t.get("id") or "") - state_dirty = True - out["events"].append({"uid": uid, "status": "adopted", - "session_id": t.get("sessionId")}) - break - else: - # No matching pre-existing serve → fresh launch path. - if await _gpus_busy(target_host): - _mark_event_status(state, uid, "skipped", - reason="GPUs busy at launch time") - state_dirty = True - out["events"].append({"uid": uid, "status": "skipped", - "reason": "GPUs busy"}) - continue - sid = await _launch_serve(payload, uid) - if sid: - _mark_event_status(state, uid, "running", - reason="launched by scheduler", - session_id=sid) - state_dirty = True - # Tag the new task with the schedule owner so window-end - # cleanup knows this is ours, not a manual launch. - fresh_state = _read_state() - for t in fresh_state.get("tasks") or []: - if isinstance(t, dict) and t.get("sessionId") == sid: - t[SCHEDULE_OWNER_KEY] = uid - break - _write_state(fresh_state) - state_dirty = False # we just wrote - out["events"].append({"uid": uid, "status": "running", - "session_id": sid}) - else: - _mark_event_status(state, uid, "skipped", - reason="serve_model rejected launch") - state_dirty = True - out["events"].append({"uid": uid, "status": "skipped", - "reason": "launch rejected"}) - - if state_dirty: - _write_state(state) - out["tick_at"] = now.isoformat() - return out - - -async def reconcile_loop() -> None: - """Forever-loop reconciler. Registered as a startup task in app.py.""" - # Stagger the first tick so we don't fight the rest of startup for - # CPU + I/O. - await asyncio.sleep(15) - while True: - try: - result = await _reconcile_once() - if result.get("events"): - logger.info(f"scheduler tick: {result}") - except Exception as e: - logger.warning(f"scheduler tick failed: {e}") - await asyncio.sleep(60) diff --git a/src/settings.py b/src/settings.py index ef8c162..09a53c9 100644 --- a/src/settings.py +++ b/src/settings.py @@ -159,12 +159,6 @@ DEFAULT_SETTINGS = { "admin_panel": "ctrl+shift+u", "cancel": "escape", }, - # Cookbook scheduler (calendar-driven serve windows). Off by default; - # the inline card in the Cookbook tab flips this. When true, the - # reconciler at src/cookbook_scheduler.py reads events from the - # designated calendar and auto-launches/kills serves. - "cookbook_scheduler_enabled": False, - "cookbook_schedule_calendar_href": "", } DEFAULT_FEATURES = { diff --git a/static/index.html b/static/index.html index 9f941b6..72544de 100644 --- a/static/index.html +++ b/static/index.html @@ -2266,7 +2266,6 @@ - diff --git a/static/js/calendar.js b/static/js/calendar.js index 4088867..31a4423 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -3346,42 +3346,3 @@ window.addEventListener('calendar-refresh', () => { const calendarModule = { openCalendar, closeCalendar, isCalendarOpen }; export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen }; export default calendarModule; - -// ── Cookbook scheduler hook ───────────────────────────────────────────── -// Lets the Cookbook tab's Schedule button open the standard calendar -// event-create form pre-filled with the model's name + cookbook YAML in -// the description, on the auto-created Cookbook calendar. Keeps the -// Schedule UX identical to creating any other calendar event — no -// custom modal, just the existing flow that users already know. -window.cookbookOpenScheduleForm = function (draft) { - // Open the calendar first so #cal-body exists for _showEventForm. - if (!_open) openCalendar(); - // Defer a tick so the modal DOM is mounted before we touch it. - setTimeout(() => { - _showEventForm(null, _today(), _today()); - setTimeout(() => { - try { - const sumEl = document.getElementById('cal-f-sum'); - if (sumEl && draft && draft.summary) sumEl.value = draft.summary; - const descEl = document.getElementById('cal-f-desc'); - if (descEl && draft && draft.description) descEl.value = draft.description; - const rrEl = document.getElementById('cal-f-rrule'); - if (rrEl && draft && draft.rrule) rrEl.value = draft.rrule; - // Calendar selector lives behind the "Add details" expand; force- - // expand so the user sees it's heading into the Cookbook calendar. - const form = document.querySelector('.cal-form-bespoke'); - if (form) form.classList.add('is-expanded'); - const calSel = document.getElementById('cal-f-cal'); - if (calSel && draft && draft.calendar_href) { - const opt = Array.from(calSel.options).find(o => o.value === draft.calendar_href); - if (opt) calSel.value = draft.calendar_href; - } - // Focus the title so the user can immediately type if they want - // to rename the event (rare, but cheap to enable). - if (sumEl) sumEl.focus(); - } catch (e) { - console.warn('cookbook schedule prefill failed:', e); - } - }, 60); - }, _open ? 0 : 80); -}; diff --git a/static/js/cookbookSchedule.js b/static/js/cookbookSchedule.js deleted file mode 100644 index 21a7659..0000000 --- a/static/js/cookbookSchedule.js +++ /dev/null @@ -1,457 +0,0 @@ -// Cookbook Schedule modal. Click the "Schedule…" button in a serve -// panel → opens this modal → user picks days + time slots → POST to -// /api/cookbook/schedule/from-cookbook which writes the calendar event. -// -// Whole feature is gated on /api/cookbook/schedule/upcoming returning -// `enabled: true`. If the server says it's disabled, this module hides -// all Schedule buttons and never opens the modal. -// -// To remove the feature entirely: delete this file + the `