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

16
app.py
View File

@@ -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():

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

View File

@@ -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 = {
"<event_uid>": {
"status": "running" | "skipped" | "ended" | "failed",
"reason": "<short string>",
"session_id": "...",
"ts": <ms epoch>,
},
...
}
"""
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)

View File

@@ -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 = {

View File

@@ -2266,7 +2266,6 @@
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>
<script type="module" src="/static/js/theme.js"></script>

View File

@@ -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);
};

View File

@@ -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 `<script>`
// tag that loads it + the `.hwfit-serve-schedule` button in
// cookbookServe.js. No other code depends on it.
(function () {
const DAYS = [
{ key: "MO", label: "Mon" },
{ key: "TU", label: "Tue" },
{ key: "WE", label: "Wed" },
{ key: "TH", label: "Thu" },
{ key: "FR", label: "Fri" },
{ key: "SA", label: "Sat" },
{ key: "SU", label: "Sun" },
];
const WEEKDAYS = ["MO", "TU", "WE", "TH", "FR"];
let _enabledCache = null;
async function isEnabled() {
if (_enabledCache !== null) return _enabledCache;
try {
const r = await fetch("/api/cookbook/schedule/upcoming?hours=1", { credentials: "same-origin" });
if (!r.ok) { _enabledCache = false; return false; }
const data = await r.json();
_enabledCache = !!data.enabled;
return _enabledCache;
} catch (_) {
_enabledCache = false;
return false;
}
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function slotRowHtml(start, end) {
return `
<div class="cookbook-schedule-slot">
<input type="time" class="cookbook-schedule-start" value="${esc(start || "09:00")}" />
<span class="cookbook-schedule-dash"></span>
<input type="time" class="cookbook-schedule-end" value="${esc(end || "17:00")}" />
<button type="button" class="cookbook-schedule-slot-remove" title="Remove slot">×</button>
</div>`;
}
function dayChipsHtml(selected) {
const sel = new Set(selected || WEEKDAYS);
return DAYS.map(d =>
`<label class="cookbook-schedule-day${sel.has(d.key) ? " active" : ""}">
<input type="checkbox" value="${d.key}" ${sel.has(d.key) ? "checked" : ""} />
${esc(d.label)}
</label>`).join("");
}
function openModal(config) {
// config = {title, preset, repo_id, cmd, host, port}
const wrap = document.createElement("div");
wrap.className = "cookbook-schedule-modal-backdrop";
wrap.innerHTML = `
<div class="cookbook-schedule-modal">
<div class="cookbook-schedule-modal-header">
<strong>Schedule: ${esc(config.title || config.preset || "model")}</strong>
<button type="button" class="cookbook-schedule-close" title="Close">×</button>
</div>
<div class="cookbook-schedule-modal-body">
<div class="cookbook-schedule-section">
<label class="cookbook-schedule-section-label">When</label>
<div class="cookbook-schedule-slots">
${slotRowHtml("09:00", "17:00")}
</div>
<button type="button" class="cookbook-schedule-add-slot">+ add another time slot</button>
</div>
<div class="cookbook-schedule-section">
<label class="cookbook-schedule-section-label">Repeat on</label>
<div class="cookbook-schedule-days">${dayChipsHtml(WEEKDAYS)}</div>
<div class="cookbook-schedule-day-quickset">
<button type="button" data-set="weekdays">Weekdays</button>
<button type="button" data-set="weekend">Weekend</button>
<button type="button" data-set="all">Every day</button>
</div>
</div>
<div class="cookbook-schedule-section">
<label class="cookbook-schedule-section-label">Until</label>
<div class="cookbook-schedule-until">
<label><input type="radio" name="until-mode" value="forever" checked /> Forever</label>
<label><input type="radio" name="until-mode" value="date" /> Until
<input type="date" class="cookbook-schedule-until-date" disabled />
</label>
</div>
</div>
<div class="cookbook-schedule-error" style="display:none;"></div>
</div>
<div class="cookbook-schedule-modal-footer">
<button type="button" class="cookbook-btn cookbook-schedule-cancel">Cancel</button>
<button type="button" class="cookbook-btn cookbook-schedule-save">Save schedule</button>
</div>
</div>`;
document.body.appendChild(wrap);
const $ = (sel) => wrap.querySelector(sel);
const $$ = (sel) => Array.from(wrap.querySelectorAll(sel));
const close = () => wrap.remove();
$(".cookbook-schedule-close").onclick = close;
$(".cookbook-schedule-cancel").onclick = close;
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
// Add / remove slot rows.
$(".cookbook-schedule-add-slot").onclick = () => {
const slots = $(".cookbook-schedule-slots");
const tmp = document.createElement("div");
tmp.innerHTML = slotRowHtml("18:00", "23:00");
slots.appendChild(tmp.firstElementChild);
};
wrap.addEventListener("click", (e) => {
if (e.target.classList && e.target.classList.contains("cookbook-schedule-slot-remove")) {
const slots = $$(".cookbook-schedule-slot");
if (slots.length > 1) e.target.closest(".cookbook-schedule-slot").remove();
}
});
// Day quickset chips.
$$(".cookbook-schedule-day-quickset button").forEach(btn => {
btn.onclick = () => {
const sel = btn.dataset.set;
const want = sel === "weekdays" ? new Set(WEEKDAYS)
: sel === "weekend" ? new Set(["SA", "SU"])
: new Set(DAYS.map(d => d.key));
$$(".cookbook-schedule-day input").forEach(inp => {
inp.checked = want.has(inp.value);
inp.closest(".cookbook-schedule-day").classList.toggle("active", inp.checked);
});
};
});
$$(".cookbook-schedule-day input").forEach(inp => {
inp.onchange = () => inp.closest(".cookbook-schedule-day").classList.toggle("active", inp.checked);
});
// Until-date radio enables / disables the date picker.
$$('input[name="until-mode"]').forEach(r => {
r.onchange = () => {
const datePicker = $(".cookbook-schedule-until-date");
datePicker.disabled = $('input[name="until-mode"]:checked').value !== "date";
};
});
$(".cookbook-schedule-save").onclick = async () => {
const slots = $$(".cookbook-schedule-slot").map(row => ({
start: row.querySelector(".cookbook-schedule-start").value,
end: row.querySelector(".cookbook-schedule-end").value,
}));
const days = $$(".cookbook-schedule-day input:checked").map(i => i.value);
const untilMode = $('input[name="until-mode"]:checked').value;
const untilDate = untilMode === "date" ? $(".cookbook-schedule-until-date").value : "";
const errEl = $(".cookbook-schedule-error");
errEl.style.display = "none";
const body = {
model: config.title || config.preset || "",
preset: config.preset,
repo_id: config.repo_id,
cmd: config.cmd,
host: config.host,
port: config.port,
slots, days,
};
if (untilDate) body.until = untilDate;
try {
const r = await fetch("/api/cookbook/schedule/from-cookbook", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json();
if (!r.ok) {
errEl.textContent = data.detail || data.error || `HTTP ${r.status}`;
errEl.style.display = "block";
return;
}
close();
if (window.toast) window.toast(`Scheduled ${slots.length} window(s) on ${days.length} day(s).`, "success");
} catch (e) {
errEl.textContent = String(e);
errEl.style.display = "block";
}
};
}
// Click-binding: any .hwfit-serve-schedule button inside a serve
// panel routes to the STANDARD calendar event-creation form, with the
// model's name pre-filled as the event title and a `cookbook:` YAML
// block in the description. The event lands on the auto-created
// "Cookbook" calendar so the reconciler picks it up. The custom
// openModal() above is kept as a fallback in case the calendar
// module hasn't loaded.
document.addEventListener("click", async (e) => {
const btn = e.target.closest && e.target.closest(".hwfit-serve-schedule");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const panel = btn.closest("[data-cookbook-serve-panel]") || btn.closest(".doclib-card-expanded") || btn.closest(".doclib-card");
const ds = panel ? panel.dataset || {} : {};
const config = {
title: ds.modelName || ds.preset || panel?.querySelector(".doclib-card-title")?.textContent?.trim() || "model",
preset: ds.preset || "",
repo_id: ds.repoId || "",
cmd: ds.cmd || "",
host: ds.host || "",
port: ds.port ? Number(ds.port) : undefined,
};
// Ensure the Cookbook calendar exists and is configured. Returns
// the href to feed into the event form.
btn.disabled = true;
let calHref = "";
try {
const r = await fetch("/api/cookbook/schedule/ensure-calendar", {
method: "POST", credentials: "same-origin",
});
if (r.ok) {
const data = await r.json();
calHref = data.href || "";
}
} catch (_) {}
btn.disabled = false;
// Build the cookbook: YAML block that goes into the event description.
// The reconciler parses this to know HOW to launch when the window
// opens. If only the title is set, the reconciler title-matches
// against saved presets.
const yamlLines = ["cookbook:"];
for (const k of ["preset", "repo_id", "cmd", "host", "port"]) {
if (config[k]) yamlLines.push(` ${k}: ${config[k]}`);
}
if (yamlLines.length === 1 && config.title) {
yamlLines.push(` preset: ${config.title}`);
}
const draft = {
summary: config.title,
description: yamlLines.join("\n"),
rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", // default: weekdays
calendar_href: calHref,
};
if (typeof window.cookbookOpenScheduleForm === "function") {
window.cookbookOpenScheduleForm(draft);
} else {
// Fallback to the legacy in-house modal if the calendar module
// hasn't loaded for some reason.
openModal(config);
}
});
// Reveal Schedule buttons once we confirm the feature is enabled.
async function refreshScheduleButtonVisibility() {
const enabled = await isEnabled();
document.querySelectorAll(".hwfit-serve-schedule").forEach(btn => {
btn.style.display = enabled ? "" : "none";
});
}
// Periodically re-check (cheap) so toggling the feature in Settings
// takes effect without a full reload.
document.addEventListener("DOMContentLoaded", () => {
refreshScheduleButtonVisibility();
setInterval(refreshScheduleButtonVisibility, 30000);
});
// Also re-check whenever a serve panel expands.
const obs = new MutationObserver(() => refreshScheduleButtonVisibility());
obs.observe(document.body, { childList: true, subtree: true });
// ── Settings card injected at the top of the Cookbook tab ─────────────
// Lives here (not in settings.js) so the whole feature is in one file.
// When you delete cookbookSchedule.js, this UI vanishes with it.
async function fetchSettings() {
try {
const r = await fetch("/api/auth/settings", { credentials: "same-origin" });
if (!r.ok) return {};
return await r.json();
} catch (_) { return {}; }
}
async function saveSettings(body) {
try {
const r = await fetch("/api/auth/settings", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.ok;
} catch (_) { return false; }
}
async function fetchCalendars() {
try {
const r = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
if (!r.ok) return [];
const d = await r.json();
// Endpoint shape varies — accept either an array or { calendars: [...] }.
const list = Array.isArray(d) ? d : (d.calendars || []);
return list.map(c => ({
href: c.href || c.url || c.id || "",
name: c.display_name || c.name || c.summary || c.href || "Calendar",
})).filter(c => c.href);
} catch (_) { return []; }
}
async function fetchUpcoming() {
try {
const r = await fetch("/api/cookbook/schedule/upcoming?hours=24", { credentials: "same-origin" });
if (!r.ok) return null;
return await r.json();
} catch (_) { return null; }
}
function buildCardHtml(s, calendars, upcoming) {
const enabled = !!s.cookbook_scheduler_enabled;
const calHref = s.cookbook_schedule_calendar_href || "";
const events = (upcoming && upcoming.events) || [];
const running = events.filter(e => e.status === "running" || e.status === "adopted").length;
const skipped = events.filter(e => e.status === "skipped" || e.status === "failed").length;
let statusLine = "";
if (!enabled) {
statusLine = "Scheduler is off. Toggle on to start launching models on a schedule.";
} else if (!calHref) {
statusLine = "Pick a calendar — events on it become serve windows.";
} else if (events.length === 0) {
statusLine = "Enabled. No scheduled windows in the next 24h.";
} else {
const parts = [`${events.length} scheduled in next 24h`];
if (running) parts.push(`${running} running now`);
if (skipped) parts.push(`${skipped} skipped`);
statusLine = parts.join(" · ");
}
const calOptions = ['<option value="">— pick a calendar —</option>']
.concat(calendars.map(c => `<option value="${esc(c.href)}"${c.href === calHref ? " selected" : ""}>${esc(c.name)}</option>`))
.join("");
return `
<div class="cookbook-schedule-card" style="border:1px solid var(--border,#2d2d33);border-radius:10px;padding:12px 14px;margin:8px 0 14px;background:var(--bg-secondary,#1a1a1e);">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-weight:600;font-size:13px;">
<input type="checkbox" class="cookbook-sched-enabled" ${enabled ? "checked" : ""} />
Cookbook scheduler <span style="opacity:.55;font-weight:400;font-size:11px;">(beta)</span>
</label>
<span class="cookbook-sched-status" style="opacity:.7;font-size:12px;flex:1;min-width:200px;">${esc(statusLine)}</span>
<button type="button" class="cookbook-sched-reconcile cookbook-btn" style="font-size:11px;padding:4px 8px;" title="Force the reconciler to run now">Reconcile now</button>
</div>
<div class="cookbook-sched-calrow" style="margin-top:10px;display:${enabled ? "flex" : "none"};align-items:center;gap:8px;flex-wrap:wrap;">
<label style="font-size:12px;opacity:.7;">Schedule calendar</label>
<select class="cookbook-sched-calendar" style="background:var(--bg-primary,#131316);color:inherit;border:1px solid var(--border,#2d2d33);border-radius:6px;padding:4px 8px;min-width:220px;">${calOptions}</select>
<span class="cookbook-sched-save-msg" style="font-size:11px;opacity:0;transition:opacity .2s;color:var(--green,#50fa7b);">Saved</span>
</div>
</div>`;
}
async function renderCard() {
const body = document.querySelector("#cookbook-modal .cookbook-body");
if (!body) return;
// Skip if cookbook modal is hidden — wait until next open.
const modal = document.getElementById("cookbook-modal");
if (modal && modal.classList.contains("hidden")) return;
let existing = body.querySelector(".cookbook-schedule-card");
const [s, cals, upcoming] = await Promise.all([fetchSettings(), fetchCalendars(), fetchUpcoming()]);
const html = buildCardHtml(s, cals, upcoming);
if (existing) {
const tmp = document.createElement("div");
tmp.innerHTML = html;
existing.replaceWith(tmp.firstElementChild);
} else {
const tmp = document.createElement("div");
tmp.innerHTML = html;
body.insertBefore(tmp.firstElementChild, body.firstChild);
}
wireCard();
}
function wireCard() {
const card = document.querySelector(".cookbook-schedule-card");
if (!card || card.dataset.wired === "1") return;
card.dataset.wired = "1";
const enabledChk = card.querySelector(".cookbook-sched-enabled");
const calSel = card.querySelector(".cookbook-sched-calendar");
const reconcileBtn = card.querySelector(".cookbook-sched-reconcile");
const saveMsg = card.querySelector(".cookbook-sched-save-msg");
const calRow = card.querySelector(".cookbook-sched-calrow");
function flashSaved() {
if (!saveMsg) return;
saveMsg.style.opacity = "1";
setTimeout(() => { saveMsg.style.opacity = "0"; }, 1500);
}
enabledChk.addEventListener("change", async () => {
_enabledCache = null; // bust cache
await saveSettings({ cookbook_scheduler_enabled: enabledChk.checked });
calRow.style.display = enabledChk.checked ? "flex" : "none";
flashSaved();
// Toggle Schedule buttons immediately + refresh card status.
refreshScheduleButtonVisibility();
setTimeout(renderCard, 200);
});
calSel.addEventListener("change", async () => {
await saveSettings({ cookbook_schedule_calendar_href: calSel.value });
flashSaved();
setTimeout(renderCard, 300);
});
reconcileBtn.addEventListener("click", async () => {
reconcileBtn.disabled = true;
reconcileBtn.textContent = "Reconciling…";
try {
await fetch("/api/cookbook/schedule/reconcile-now", { method: "POST", credentials: "same-origin" });
} catch (_) {}
reconcileBtn.disabled = false;
reconcileBtn.textContent = "Reconcile now";
renderCard();
});
}
// Re-render the card whenever the cookbook modal becomes visible.
function watchCookbookOpen() {
const modal = document.getElementById("cookbook-modal");
if (!modal) return;
let lastHidden = modal.classList.contains("hidden");
const mo = new MutationObserver(() => {
const nowHidden = modal.classList.contains("hidden");
if (lastHidden && !nowHidden) renderCard();
lastHidden = nowHidden;
});
mo.observe(modal, { attributes: true, attributeFilter: ["class"] });
// Also render on first open if modal is already visible at load time.
if (!lastHidden) renderCard();
}
document.addEventListener("DOMContentLoaded", watchCookbookOpen);
// Settings tab may load AFTER DOMContentLoaded; recheck once.
setTimeout(watchCookbookOpen, 500);
})();

View File

@@ -744,14 +744,7 @@ function _rerenderCachedModels() {
// pushes Cancel + Launch to the right.
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
// Launch + Schedule pair. The little ° button sits flush against
// Launch and opens the calendar event-creation form pre-filled
// with this model's config. Hidden until cookbookSchedule.js
// confirms the scheduler feature flag is on.
panelHtml += `<span class="hwfit-serve-launch-group">`;
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule" type="button" title="Schedule this model on a recurring window" aria-label="Schedule" style="display:none;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>`;
panelHtml += `</span>`;
panelHtml += `</div>`;
panelHtml += `</div>`;

View File

@@ -35736,122 +35736,3 @@ body.theme-frosted .modal {
is already 16px and never zoomed leave it so we don't shrink it. */
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
}
/* ── Cookbook Schedule modal (feature-flagged) ─────────────────────── */
.cookbook-schedule-modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
}
.cookbook-schedule-modal {
background: var(--bg-secondary, #1e1e22);
color: var(--text-primary, #e6e6e6);
border: 1px solid var(--border, #2d2d33);
border-radius: 12px; width: min(560px, 92vw); max-height: 90vh;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,.5);
}
.cookbook-schedule-modal-header {
padding: 14px 18px; border-bottom: 1px solid var(--border, #2d2d33);
display: flex; align-items: center; justify-content: space-between;
}
.cookbook-schedule-close {
background: transparent; border: none; color: inherit;
font-size: 22px; line-height: 1; cursor: pointer; padding: 0 6px;
}
.cookbook-schedule-modal-body { padding: 16px 18px; overflow-y: auto; }
.cookbook-schedule-section { margin-bottom: 16px; }
.cookbook-schedule-section-label {
display: block; font-size: 12px; opacity: .7;
margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em;
}
.cookbook-schedule-slot {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
}
.cookbook-schedule-slot input[type="time"] {
background: var(--bg-primary, #131316); color: inherit;
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
padding: 6px 8px; font: inherit;
}
.cookbook-schedule-slot-remove {
background: transparent; border: 1px solid var(--border, #2d2d33);
color: inherit; border-radius: 6px; width: 28px; height: 28px;
cursor: pointer; font-size: 18px; line-height: 1;
}
.cookbook-schedule-add-slot {
background: transparent; color: var(--accent, #6aa8ff);
border: 1px dashed var(--border, #2d2d33); border-radius: 6px;
padding: 6px 10px; cursor: pointer; font: inherit; margin-top: 4px;
}
.cookbook-schedule-days {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px;
}
.cookbook-schedule-day {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 10px; border: 1px solid var(--border, #2d2d33);
border-radius: 999px; cursor: pointer; font-size: 13px;
user-select: none;
}
.cookbook-schedule-day input { display: none; }
.cookbook-schedule-day.active {
background: var(--accent, #6aa8ff); color: #fff;
border-color: var(--accent, #6aa8ff);
}
.cookbook-schedule-day-quickset {
display: flex; gap: 6px; flex-wrap: wrap;
}
.cookbook-schedule-day-quickset button {
background: transparent; color: var(--text-secondary, #aaa);
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
padding: 4px 8px; cursor: pointer; font-size: 12px;
}
.cookbook-schedule-until { display: flex; gap: 16px; flex-wrap: wrap; }
.cookbook-schedule-until label {
display: inline-flex; align-items: center; gap: 6px;
}
.cookbook-schedule-until-date {
background: var(--bg-primary, #131316); color: inherit;
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
padding: 4px 8px;
}
.cookbook-schedule-error {
color: #ff6b6b; background: rgba(255,107,107,.08);
border: 1px solid rgba(255,107,107,.3); border-radius: 6px;
padding: 8px 12px; font-size: 13px;
}
.cookbook-schedule-modal-footer {
padding: 12px 18px; border-top: 1px solid var(--border, #2d2d33);
display: flex; justify-content: flex-end; gap: 8px;
}
/* Mobile (Firefox + others): single-column slot rows + larger touch targets. */
@media (max-width: 600px) {
.cookbook-schedule-modal { width: 96vw; }
.cookbook-schedule-day { padding: 8px 14px; font-size: 14px; }
}
/* Launch+Schedule paired button group. The ° button is a tiny icon
button glued to the right side of Launch with a thin divider. */
.hwfit-serve-launch-group {
display: inline-flex;
align-items: stretch;
vertical-align: middle;
}
.hwfit-serve-launch-group .hwfit-serve-launch {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
margin-right: 0 !important;
}
.hwfit-serve-launch-group .hwfit-serve-schedule {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-left: 1px solid rgba(255,255,255,.18) !important;
padding: 0 8px !important;
min-width: 26px;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.hwfit-serve-launch-group .hwfit-serve-schedule svg {
flex-shrink: 0;
}