mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
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.
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
// 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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 (_) {} }
|
||||
Reference in New Issue
Block a user