Files
odysseus/static/js/cookbookSchedule.js
pewdiepie-archdaemon e2f449f4ef Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick
- Schedule cookbook serves through the existing ScheduledTask system: the
  serve preset gets a ^ button next to Launch that opens a daily/hourly/
  weekly form mirroring the admin-switch style; the schedule action runs
  action_cookbook_serve, which delegates to /api/model/serve and stamps
  the resulting task with _scheduledStopAtMs. A background
  cookbook_serve_lifecycle loop ticks every 60s and kills any serve
  whose window has ended, also dropping the auto-registered endpoint
  so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
  re-checks tmux has-session, and surfaces an error toast (leaving the
  row) when the kill failed. Previously fire-and-forget, so a failed
  SSH/tmux call silently left the live serve running while the row
  vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
  serve-/cookbook- session-id prefix; any tmux session whose pane is
  running a known model-server process gets auto-pulled into Running.
  Without this loosening, a cookbook-launched serve whose tmux id
  fell back to a bare number was invisible — you couldn't see it,
  let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
  (no more monitor-mode reattach to a systemd/Docker ollama Stop can't
  reach). The handler pre-picks a free port by probing the target
  host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
  AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
  instead of localhost, matching the URL /setup adds for Ollama by
  hand. Local cookbook serves now produce a chat-reachable endpoint
  on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
  linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
  sorts above the rest, so scheduler-launched serves are easy to find.
2026-06-05 14:41:43 +09:00

387 lines
17 KiB
JavaScript

// Cookbook Schedule — opens a small inline form (styled with the app's
// existing .cookbook-* classes) that creates a ScheduledTask with
// action=cookbook_serve. Mounted from two places:
//
// 1. The ^ button next to Launch in a serve panel.
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
// programmatically clicks the ^ button so this module owns the
// single source of truth).
//
// Feedback uses uiModule.showToast() — the same toast the rest of the
// app uses for "Saved", "Favorited", etc. — so the success message
// doesn't introduce a parallel notification style.
//
// To remove: delete this file + the <script> tag in index.html + the
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
// registration line in app.py.
try { (function () {
function _safe(fn) {
return function () {
try { return fn.apply(this, arguments); }
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// Cached handle to the ui.js showToast function. Bound lazily on
// first use because ui.js is an ES module — it's not on `window`
// unless something else has explicitly exposed it.
let _toastFn = null;
async function _getToast() {
if (_toastFn) return _toastFn;
try {
const m = await import("/static/js/ui.js");
_toastFn = m.default?.showToast || m.showToast || null;
} catch (_) { _toastFn = null; }
return _toastFn;
}
// Optional opts: {action, onAction, duration, leadingIcon}
async function toast(msg, opts) {
const fn = await _getToast();
if (fn) {
try { fn(msg, opts); return; } catch (_) {}
}
try { console.log("[toast]", msg); } catch (_) {}
}
// Cached handle to the tasks module so the success toast's "Open"
// action can jump straight to the new task in the Tasks tab.
let _tasksMod = null;
async function _getTasksMod() {
if (_tasksMod) return _tasksMod;
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
return _tasksMod;
}
async function openTaskInTasksTab(taskId) {
const m = await _getTasksMod();
if (m && typeof m.openTasks === "function") {
try { m.openTasks(taskId); return; } catch (_) {}
}
// Last-resort fallback: click the sidebar Tasks button.
document.getElementById("tool-tasks-btn")?.click();
}
const DAYS = [
{ k: "MO", l: "Mon", idx: 0 },
{ k: "TU", l: "Tue", idx: 1 },
{ k: "WE", l: "Wed", idx: 2 },
{ k: "TH", l: "Thu", idx: 3 },
{ k: "FR", l: "Fri", idx: 4 },
{ k: "SA", l: "Sat", idx: 5 },
{ k: "SU", l: "Sun", idx: 6 },
];
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
// Resolve the model identity from the closest .memory-item card —
// that's the canonical container the cookbook serve UI uses, with
// the model repo on data-repo. We do NOT grab the title via
// textContent, because the title row also contains inline status
// pills ("running", "downloading") and an "HF ↗" link — pulling all
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
// preset lookup in action_cookbook_serve.
function readPanelConfig(arrowBtn) {
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
const panel = arrowBtn.closest(".hwfit-serve-panel");
const repo = item?.dataset?.repo
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|| "";
// Title = last segment of the repo (after the final /), which is
// exactly what the cookbook UI renders in the card title and what
// the preset registry uses as its short name. e.g.
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
// Falls back to data-modelName or the bare repo for ollama-style
// entries that don't have a slash.
let title = "";
if (repo) {
title = repo.includes("/") ? repo.split("/").pop() : repo;
}
if (!title) {
title = item?.dataset?.modelName || "model";
}
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
}
function buildFormHtml(cfg) {
return `
<div class="hwfit-schedule-form cookbook-panel">
<div class="hwfit-schedule-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
<span class="hwfit-schedule-title-spacer"></span>
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
<span class="admin-switch hwfit-schedule-mirror-switch">
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
<span class="admin-slider"></span>
</span>
</label>
</div>
<div class="hwfit-schedule-row">
<label class="hwfit-schedule-field">
<span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
</label>
<label class="hwfit-schedule-field">
<span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label>
</div>
<div class="hwfit-schedule-row hwfit-schedule-days-row">
<span class="hwfit-schedule-label">Days</span>
<div class="hwfit-sched-days">
${DAYS.map(d => `
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
`).join("")}
</div>
<span class="hwfit-schedule-actions-spacer"></span>
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span>Cancel</span>
</button>
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Save</span>
</button>
</div>
<div class="hwfit-sched-err"></div>
</div>`;
}
function openForm(arrowBtn) {
const cfg = readPanelConfig(arrowBtn);
const anchor = cfg.panel
|| cfg.item
|| arrowBtn.closest(".cookbook-saved-item")
|| arrowBtn.parentElement?.parentElement
|| arrowBtn.parentElement;
if (!anchor) {
toast("Couldn't find a panel to mount the schedule form");
return;
}
// Toggle.
const existing = anchor.querySelector(".hwfit-schedule-form");
if (existing) { existing.remove(); return; }
const tmp = document.createElement("div");
tmp.innerHTML = buildFormHtml(cfg);
const form = tmp.firstElementChild;
anchor.appendChild(form);
setTimeout(() => {
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
}, 50);
wireForm(form, cfg);
}
function wireForm(form, cfg) {
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
});
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
const startTime = form.querySelector(".hwfit-sched-start").value;
const endTime = form.querySelector(".hwfit-sched-end").value;
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
const errEl = form.querySelector(".hwfit-sched-err");
errEl.textContent = "";
errEl.classList.remove("is-visible");
function fail(msg) {
errEl.textContent = msg;
errEl.classList.add("is-visible");
}
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
return fail("Start and end must be HH:MM");
}
if (!days.length) {
return fail("Pick at least one day");
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
let dur = (eh * 60 + em) - (sh * 60 + sm);
if (dur <= 0) dur += 24 * 60;
// The backend stores scheduled_time as UTC. The user picks
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
// shows ~9 hours later instead of "in 5 min". Mirror what
// tasks.js does via its _localTimeToUtc helper.
const _localHHMMToUtc = (hhmm) => {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};
const startUtc = _localHHMMToUtc(startTime);
const [shUtc, smUtc] = startUtc.split(":").map(Number);
const allDays = days.length === 7;
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
const sched = {};
if (allDays) {
sched.schedule = "daily";
sched.scheduled_time = startUtc;
} else if (weekdaysOnly) {
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
} else if (days.length === 1) {
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
sched.schedule = "weekly";
sched.scheduled_time = startUtc;
sched.scheduled_day = dayIdx;
} else {
const dayNum = days.map(k => {
const i = DAYS.find(d => d.k === k).idx;
return i === 6 ? 0 : i + 1;
});
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
}
// Name: "Serve: <full model name>" — pulled from .memory-item-title
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
// not a placeholder like "model".
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
const payload = {
name: `Serve: ${fullName}`,
task_type: "action",
action: "cookbook_serve",
trigger_type: "schedule",
prompt: JSON.stringify({
preset: fullName,
repo_id: cfg.repo_id || "",
host: cfg.host || "",
end_after_min: dur,
}),
...sched,
};
const saveBtn = form.querySelector(".hwfit-sched-save");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
const r = await fetch("/api/tasks", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok || data.error) {
fail(data.error || data.detail || `HTTP ${r.status}`);
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
return;
}
if (mirrorToCalendar) {
// Mirror onto a dedicated "Cookbook" calendar so the user can
// toggle the whole set on/off as a unit in the calendar UI.
// Best-effort: if anything here fails, we still consider the
// task creation a success (the task itself works regardless).
try {
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
const calsBody = calsRes.ok ? await calsRes.json() : {};
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
if (!cookbookCal) {
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
method: "POST", credentials: "same-origin",
});
if (mk.ok) {
const mkData = await mk.json();
// The create endpoint returns {ok, id, name, color}; the
// list endpoint returns {href, name, color}. The two map
// 1:1 (href === id) so we synthesize the same shape.
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
}
}
// The `cookbook_task_id:` marker on its own line lets
// calendar.js's event-form code detect that this event was
// created from a Cookbook schedule and render an
// "Open task" button alongside the description, so the user
// can jump straight to the source task from the calendar UI.
const evBody = {
summary: payload.name,
dtstart: new Date().toISOString(),
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
all_day: false,
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
+ `cookbook_task_id: ${data.id || ""}`,
rrule: weekdaysOnly
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
color: "#3b82f6",
};
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
const evRes = await fetch("/api/calendar/events", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(evBody),
});
const evData = evRes.ok ? await evRes.json() : null;
// Stash the event uid + calendar href on the task's prompt
// JSON so the task-delete hook can cascade the calendar
// cleanup. PATCH the task with an updated prompt.
if (evData && (evData.uid || evData.id)) {
const eventUid = evData.uid || evData.id;
try {
const updatedPrompt = JSON.stringify({
...JSON.parse(payload.prompt),
cookbook_event_uid: eventUid,
cookbook_event_calendar: cookbookCal?.href || "",
});
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
// here silently failed (no such method on that route), so
// the task never got the cookbook_event_uid marker and the
// server-side delete-cascade had nothing to follow when the
// user later deleted the task.
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: updatedPrompt }),
});
} catch (_) {}
}
} catch (_) {}
}
form.remove();
const newTaskId = data.id || data.task_id || "";
toast(`Created task: Serve: ${fullName}`, {
leadingIcon: "check",
action: "Open",
duration: 5000,
onAction: () => openTaskInTasksTab(newTaskId),
});
} catch (e) {
fail(String(e));
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${e}`);
}
}));
}
document.addEventListener("click", _safe((e) => {
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
if (!arrow) return;
e.preventDefault();
e.stopPropagation();
openForm(arrow);
}));
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }