diff --git a/app.py b/app.py index 7a00722..f34f0ec 100644 --- a/app.py +++ b/app.py @@ -640,6 +640,14 @@ 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()) @@ -1061,6 +1069,14 @@ 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 new file mode 100644 index 0000000..b298311 --- /dev/null +++ b/routes/cookbook_schedule_routes.py @@ -0,0 +1,208 @@ +"""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() + + return router diff --git a/src/cookbook_scheduler.py b/src/cookbook_scheduler.py new file mode 100644 index 0000000..f237343 --- /dev/null +++ b/src/cookbook_scheduler.py @@ -0,0 +1,456 @@ +"""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/static/index.html b/static/index.html index 72544de..9f941b6 100644 --- a/static/index.html +++ b/static/index.html @@ -2266,6 +2266,7 @@ + diff --git a/static/js/cookbookSchedule.js b/static/js/cookbookSchedule.js new file mode 100644 index 0000000..6a5cd82 --- /dev/null +++ b/static/js/cookbookSchedule.js @@ -0,0 +1,244 @@ +// 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 `