// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00, // summarise …". The list (each task's next run, how its last run went, a switch to pause it), a // task's page (what it does, when, its settings, the next runs, its history with each run's // result, Run now, edit, pause, delete), and a task said in words: the server reads them into a // rule and an instruction (compile), the next runs are shown, the settings chosen, and only then // is it saved. The server runs them; this page only reads and asks. // // The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract // fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`): // // GET /schedules { schedules: [schedule] }, newest first // POST /schedules { title, instruction, request, rule, model, notify, // mode, effort, agents } -> 201 { schedule } // GET /schedules/{id} { schedule } // PATCH /schedules/{id} any of the create fields -> { schedule } // DELETE /schedules/{id}?keep_chat=true 204 // POST /schedules/{id}/pause | /resume { schedule } // POST /schedules/{id}/run 202 { run }: now, not counted against the rule // GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first // GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted) // POST /schedules/compile { text } -> { ok, reason, title, instruction, // target, rule, description, next_runs, timezone } // POST /schedules/preview { rule } -> { rule, description, next_runs, timezone } // GET /schedules/models { models: [model + free, busy, chosen], instance, mine } // PUT /schedules/models { models: [id] }: the person's narrowing // GET /events `schedules` frames, { version, schedules }: followed // by the app (Rust, watch.rs), handed here as the // `schedules` event // // GET /devices { devices: [{ id, name, host, scheduled, max_mode }] }: // the linked computers, each saying whether it // accepts scheduled work // // Where it runs (`runs_on`, server 2.0.0-beta.9): null on the server, else `{ device, device_name, // cwd, mode, approval_wait_minutes }` -- an agent chat on that linked computer, in that folder and // mode (never Plan). A run waiting for the computer says `waiting_for: "device"`; one it refused // (it does not accept scheduled work) fails with the computer's own words. // // A server without them answers 404: the page says this server has no scheduled tasks yet. // Everything a task says (its title, instruction, a run's result or error) is the person's or a // model's words, set as text; a result's Markdown goes through markdown.js. import { $, h, clear, icon, ICONS, when } from "./dom.js" import { api, get, post, patch, del, why, listen } from "./bridge.js" import { ask, menu, popover } from "./popup.js" import { avatar, levelsOf, levelWord } from "./models.js" import { markdown } from "./markdown.js" let ctx = null // { status(), models(), me(), thisDevices(), openChat(id), show() } let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? } let tasks = [] let missing = false // the server has no scheduling routes let shown = null // the task whose page is open: { id, last, running } const STATUS = { ok: () => t("Done"), silent: () => t("Nothing to report"), failed: () => t("Failed"), missed: () => t("Missed"), skipped: () => t("Skipped"), waiting: () => t("Waiting for a free model"), running: () => t("Running"), } const NOTIFY = { matters: () => t("When it matters"), always: () => t("Always"), never: () => t("Never"), } const NOTIFY_HINT = { matters: () => t("A result, but not a run with nothing to report."), always: () => t("Every result, even with nothing to report."), never: () => t("Only in its history."), } const MODE = { fresh: () => t("A new chat for each run"), continue: () => t("One chat that grows"), } const TIMING = { late: () => t("late"), catch_up: () => t("caught up"), manual: () => t("run by hand"), } const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS const enc = encodeURIComponent const RUNS_PAGE = 20 function statusChip(run, running) { if (running) return h("span", { class: "schip is-running", text: STATUS.running() }) if (!run) return h("span", { class: "schip", text: t("Not run yet") }) const word = run.status === "waiting" && run.waiting_for === "device" ? t("Waiting for the computer") : STATUS[run.status]?.() || run.status // A missed run counts the slots that passed unrun; any other run says how many it followed. const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : "" return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` }) } /** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */ function stamp(value) { const d = new Date(value) if (!value || Number.isNaN(d.getTime())) return "" return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) } function nextWords(task) { if (!task.enabled) return t("Paused") if (!task.next_run) return t("No more runs") return `${when(task.next_run)} · ${stamp(task.next_run)}` } function onPage() { return !$("page-scheduled").hidden } // --- Reading ------------------------------------------------------------------------------------ async function load() { try { tasks = (await get("/schedules")).schedules || [] missing = false } catch (err) { tasks = [] missing = err?.status === 404 || err?.status === 405 if (!missing) throw err } } /** The scheduling models, in the order they are tried; null when the server has none to say. */ async function schedulingModels() { try { return await get("/schedules/models") } catch { return null } } /** Show the page: the list, or one task's page (`id`). */ export async function showScheduled(id) { view = id ? { name: "task", id } : { name: "list" } await draw() } async function draw() { const body = clear($("scheduled-body")) clear($("scheduled-tools")) clear($("scheduled-place")) shown = null body.append(h("p", { class: "muted page__loading", text: t("Loading…") })) try { if (view.name === "task") await drawTask(view.id) else if (view.name === "form") await drawForm(view.task) else { await load() drawList() } } catch (err) { clear(body).append(h("p", { class: "status is-error", text: why(err) })) } } /** The page's title; with a way back, "Scheduled ›" before it. */ function header(title, back) { const h1 = $("scheduled-title") if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title })) else h1.textContent = title } function go(next) { view = next draw() } // --- The list ------------------------------------------------------------------------------------- function drawList() { header(t("Scheduled")) const tools = clear($("scheduled-tools")) const body = clear($("scheduled-body")) if (missing) { body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it."))) return } tools.append( h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })), h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })), ) if (!tasks.length) { body.append( empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")), h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))), ) return } const list = h("ul", { class: "tasks-list", role: "list" }) for (const task of tasks) list.append(taskRow(task)) body.append(list) } function empty(title, text) { return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text })) } /** One task: icon · title and its rule in words · next run · last status · pause switch. */ function taskRow(task) { const toggle = h("input", { type: "checkbox", role: "switch", checked: !!task.enabled, "aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }), title: task.enabled ? t("Pause") : t("Resume"), onclick: (e) => e.stopPropagation(), onchange: (e) => setEnabled(task, e.target.checked), }) return h( "li", { class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } }, h( "button", { type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) }, h("span", { class: "task__icon" }, icon(ICONS.clock)), h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })), h( "span", { class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" }, h("span", { class: "task__label", text: t("Next run") }), h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }), ), h("span", { class: "task__last" }, statusChip(task.last_run, task.running)), ), h("label", { class: "toggle task__toggle" }, toggle), ) } /** Pause or resume: the server's own verbs, which answer the schedule as it now is. */ async function setEnabled(task, on) { try { const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`) Object.assign(task, r.schedule || { enabled: on }) } catch (err) { await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") }) } if (view.name === "list") drawList() else draw() } /** * The scheduling models: the ones scheduled work may run on, in the order they are tried, each * free now or busy (and why). A task without a model of its own runs on the first free one of * those ticked; ticking none means all of them. */ async function modelsPanel(anchor) { const data = await schedulingModels() if (!data) return let mine = new Set(data.mine || []) const said = h("p", { class: "status is-error", hidden: true }) const save = async () => { try { const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) }) Object.assign(data, r) mine = new Set(r.mine || []) said.hidden = true } catch (err) { said.textContent = why(err) said.hidden = false } pop.redraw() } const content = () => h( "div", { class: "pop__body smodels" }, h("p", { class: "pop__title", text: t("Models for scheduled tasks") }), h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }), h( "ul", { class: "smodels__list", role: "list" }, data.models.map((m) => h( "li", null, h( "label", { class: "smodel" }, h("input", { type: "checkbox", checked: mine.has(m.id), onchange: (e) => { if (e.target.checked) mine.add(m.id) else mine.delete(m.id) save() }, }), avatar(m), h("span", { class: "smodel__name", text: m.name }), h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }), ), ), ), ), data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }), said, ) const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") }) } // --- One task ----------------------------------------------------------------------------------- async function drawTask(id) { const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)]) const task = r.schedule shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running } header(task.title || t("Scheduled"), () => go({ name: "list" })) const tools = clear($("scheduled-tools")) tools.append( h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })), h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })), h( "button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => menu(e.currentTarget, [ { label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) }, ...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []), null, { label: t("Delete"), danger: true, run: () => remove(task) }, ]), }, icon(ICONS.more), ), ) const [smodels, devices] = await Promise.all([schedulingModels(), linkedDevices()]) const models = smodels?.models || ctx.models() const body = clear($("scheduled-body")) body.append( h( "section", { class: "sched" }, task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null, h( "div", { class: "sched__facts" }, fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""), fact(t("Next run"), h("span", { text: nextWords(task) })), fact(t("Last run"), statusChip(task.last_run, task.running)), fact(t("Model"), modelWords(models, task.model)), fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })), fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })), fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })), fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })), "runs_on" in task ? whereFact(task.runs_on, devices) : null, ), h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null), upcoming(task.next_runs || []), ), history(task, page.runs || [], page.more), ) } // --- Where it runs ------------------------------------------------------------------------------ const RUN_MODES = { manual: () => t("Manual — asks first"), edit: () => t("Edit — asks before commands"), auto: () => t("Auto — asks nothing"), } /** The account's linked computers (`GET /devices`), or null where the server cannot say. */ async function linkedDevices() { try { return (await get("/devices")).devices || [] } catch { return null } } function deviceLabel(d) { const name = d.name || d.host || d.id return (ctx.thisDevices?.() || []).includes(d.id) ? t("This computer (%(name)s)", { name }) : name } /** "Where it runs": the server, or a computer and a folder, with what it is doing about it now. */ function whereFact(on, devices) { if (!on) return fact(t("Where it runs"), h("span", { text: t("On this server") })) const d = (devices || []).find((x) => x.id === on.device) const name = d ? deviceLabel(d) : on.device_name || t("A linked computer") let state = "" if (devices && !d) state = t("Away now: a run waits for it, then counts as missed.") else if (d && d.scheduled === false) state = t("It does not accept scheduled work: turn on Accept scheduled work there.") const hint = [RUN_MODES[on.mode]?.() || on.mode, t("approvals wait %(n)s min", { n: on.approval_wait_minutes }), state].filter(Boolean).join(" · ") return h( "div", { class: `fact${state ? " is-warning" : ""}` }, h("span", { class: "fact__label", text: t("Where it runs") }), h("span", { class: "fact__value" }, h("bdi", { text: name }), h("span", { class: "fact__path", title: on.cwd }, h("bdi", { text: on.cwd }))), h("span", { class: "fact__hint", text: hint }), ) } function fact(label, value, hint) { return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null) } function modelWords(models, id) { if (!id) return h("span", { text: t("The first free scheduling model") }) const m = models.find((x) => x.id === id) return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id }) } function upcoming(next) { if (!next.length) return null return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) }))))) } /** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */ function history(task, runs, more) { const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") })) if (!runs.length) { box.append(h("p", { class: "muted", text: t("It has not run yet.") })) return box } const list = h("ul", { class: "runs" }) const add = (page) => { for (const run of page) list.append(runRow(task, run)) } add(runs) box.append(list) if (more) { const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") }) earlier.addEventListener("click", async () => { earlier.disabled = true try { const last = list.lastElementChild?.dataset.id || "" const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`) add(r.runs || []) if (!r.more) earlier.remove() else earlier.disabled = false } catch (err) { earlier.disabled = false await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") }) } }) box.append(earlier) } return box } /** What a run that did not finish well says: waiting and until when, or its error. */ function runNote(run) { if (run.status === "waiting") { const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : "" return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ") } return run.error?.text || "" } function runRow(task, run) { const model = run.model || {} const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : "" const hasResult = run.message_id || run.report_path || run.preview const detail = h("div", { class: "run__detail", hidden: true }) const open = hasResult ? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") }) : null open?.addEventListener("click", async () => { const opening = detail.hidden open.setAttribute("aria-expanded", String(opening)) if (!opening) { detail.hidden = true return } detail.hidden = false detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") })) try { const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`) detail.replaceChildren( r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }), r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null, r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null, ) } catch (err) { detail.replaceChildren(h("p", { class: "status is-error", text: why(err) })) } }) const note = runNote(run) return h( "li", { class: "run", dataset: { id: run.id } }, h("span", { class: "run__status" }, statusChip(run)), h( "span", { class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" }, h("span", { text: stamp(run.started_at || run.due_at) }), TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null, ), h("span", { class: "run__model", title: switched }, h("span", { text: model.name || (task.runs_on ? task.runs_on.device_name || "" : "") }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null), open || h("span", { class: "run__error", title: note, text: note }), detail, ) } async function runNow(task, btn) { btn.disabled = true try { await post(`/schedules/${enc(task.id)}/run`) await draw() } catch (err) { btn.disabled = false await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") }) } } async function remove(task) { const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true }) if (!yes) return try { await del(`/schedules/${enc(task.id)}?keep_chat=true`) go({ name: "list" }) } catch (err) { await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") }) } } // --- A task said in words: new, or an existing one edited --------------------------------------- /** * The form. The words first: Read them asks the server (compile) for the rule, its title and * instruction, and the next runs; the title and instruction can then be changed. Below, the * settings the server keeps per task. An existing task starts from what it is: its words, title, * instruction, and its rule read back (preview) -- a paused task's next runs too. */ async function drawForm(task) { const editing = !!task header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" })) clear($("scheduled-tools")) const [sm, devices] = await Promise.all([schedulingModels(), linkedDevices()]) const smodels = sm?.models || [] // Offered where the server can run a schedule on a computer: its devices say whether they accept. const canPlace = !!devices && (devices.some((d) => "scheduled" in d) || !!task?.runs_on) // What will be saved: the rule only from the server's reading of words (or the task's own). const draft = { request: task?.request || "", title: task?.title || "", instruction: task?.instruction || "", rule: task?.rule || null, target: task?.target || "chat", model: task?.model || "", effort: task?.effort || "", agents: task?.agents || null, notify: task?.notify || "matters", mode: task?.mode || "fresh", runs_on: task?.runs_on ? { device: task.runs_on.device, cwd: task.runs_on.cwd, mode: task.runs_on.mode, approval_wait_minutes: task.runs_on.approval_wait_minutes } : null, } let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request }) const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") }) const said = h("p", { class: "status is-error", hidden: true }) const when_ = h("span", { class: "sched__desc", id: "sched-when" }) const zone = h("p", { class: "hint" }) const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") }) const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction }) const settings = h("div", { class: "fields sched__settings" }) const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") }) const saveSaid = h("p", { class: "status is-error", hidden: true }) const nextBox = h("div", { class: "sched__nextbox" }) const manual = manualRule((rule) => { post("/schedules/preview", { rule }) .then((r) => { reading = r draft.rule = r.rule || {} drawWhen() }) .catch((err) => { saveSaid.textContent = why(err) saveSaid.hidden = false }) }) const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) }) const rest = h( "div", { class: "sched__card", hidden: !editing }, h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)), manualBtn, manual, nextBox, h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }), instruction, h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }), settings, saveSaid, h("div", { class: "set__buttons" }, saveBtn), ) const drawWhen = () => { const r = reading const next = r?.next_runs || [] const runs = !!(draft.rule && Object.keys(draft.rule).length) when_.textContent = (runs && r?.description) || t("These words give no run in the future.") when_.title = when_.textContent when_.classList.toggle("is-error", !runs) zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : "" nextBox.replaceChildren(upcoming(next) || "") saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length) } /** Where it runs: the server, or a linked computer that accepts scheduled work, its folder and mode. */ const whereFields = () => { const on = draft.runs_on const listed = devices || [] const where = h( "select", { id: "sched-where", onchange: (e) => { const id = e.target.value if (!id) draft.runs_on = null else { const mine = (ctx.thisDevices?.() || []).includes(id) const roots = ctx.status?.()?.remote?.roots || [] draft.runs_on = { device: id, cwd: draft.runs_on?.cwd || (mine ? roots[0] || "" : ""), mode: draft.runs_on?.mode || "edit", approval_wait_minutes: draft.runs_on?.approval_wait_minutes || 30 } } drawSettings() settings.querySelector(draft.runs_on ? "#sched-cwd" : "#sched-where")?.focus() }, }, h("option", { value: "", text: t("On this server"), selected: !on }), listed.map((d) => h("option", { value: d.id, text: d.scheduled === false ? `${deviceLabel(d)} — ${t("does not accept scheduled work")}` : deviceLabel(d), disabled: d.scheduled === false && on?.device !== d.id, selected: on?.device === d.id })), on && !listed.some((d) => d.id === on.device) ? h("option", { value: on.device, text: `${task?.runs_on?.device_name || on.device} — ${t("not linked now")}`, selected: true }) : null, ) const out = [field(t("Where it runs"), where, on ? t("A run on a computer is an agent chat there. The computer must allow it: “Accept scheduled work” in its LLeMbas CLI (lembas service scheduled on).") : "", "sched-where")] if (!on) return out const cwd = h("input", { type: "text", id: "sched-cwd", value: on.cwd, placeholder: "/home/…", spellcheck: "false", oninput: (e) => (on.cwd = e.target.value.trim()) }) const mode = h( "select", { id: "sched-runmode", onchange: (e) => (on.mode = e.target.value) }, Object.entries(RUN_MODES).map(([v, word]) => h("option", { value: v, text: word(), selected: on.mode === v })), ) const wait = h("input", { type: "number", id: "sched-wait", min: "1", max: "1440", step: "1", value: String(on.approval_wait_minutes || 30), oninput: (e) => (on.approval_wait_minutes = Number(e.target.value) || 30) }) out.push( field(t("Folder on the computer"), cwd, t("A full path, inside a folder the computer allows remote work in."), "sched-cwd"), field(t("Mode"), mode, t("Edit unless you choose otherwise; Auto runs commands without asking. The computer may lower it."), "sched-runmode"), field(t("An approval waits (minutes)"), wait, t("A command the run needs approved comes to your phone as a card. Unanswered by then, it counts as denied."), "sched-wait"), ) return out } const drawSettings = () => { const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model) const select = (id, table, key, onChange) => h( "select", { id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) }, Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })), ) const modelSelect = h( "select", { id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) }, h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }), smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })), draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null, ) const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : [] const effort = h( "select", { id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) }, h("option", { value: "", text: t("The model's own"), selected: !draft.effort }), levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })), // A level the task has that its model no longer offers stays, as it is, until changed. draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null, ) const agents = h( "select", { id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) }, h("option", { value: "", text: t("None"), selected: !draft.agents }), Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })), ) settings.replaceChildren( ...(canPlace ? whereFields() : []), field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"), field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"), field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"), field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"), field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"), ) settings.querySelector("#sched-mode").addEventListener("change", drawSettings) } const form = h( "form", { class: "sched sched--new" }, h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }), words, h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }), h("div", { class: "set__buttons" }, readBtn), said, rest, ) form.addEventListener("submit", async (e) => { e.preventDefault() const text = words.value.trim() if (!text) return words.focus() readBtn.disabled = true said.hidden = true try { const c = await post("/schedules/compile", { text }) draft.request = text draft.rule = c.rule || {} if (c.title || !editing) title.value = c.title || "" if (c.instruction || !editing) instruction.value = c.instruction || text if (c.target) draft.target = c.target reading = c if (!c.ok && c.reason) { said.textContent = c.reason said.hidden = false } // The words gave no rule: the form for one, rather than a task that cannot run. if (!(c.rule && Object.keys(c.rule).length)) { manual.hidden = false manualBtn.hidden = true } rest.hidden = false drawWhen() ;(c.ok ? saveBtn : instruction).focus() } catch (err) { said.textContent = why(err) said.hidden = false } finally { readBtn.disabled = false } }) words.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit() }) saveBtn.addEventListener("click", async () => { saveBtn.disabled = true saveSaid.hidden = true const body = { request: draft.request || words.value.trim(), title: title.value.trim(), instruction: instruction.value.trim(), rule: draft.rule, target: draft.target, model: draft.model, effort: draft.effort, agents: draft.agents, notify: draft.notify, mode: draft.mode, } if (canPlace) body.runs_on = draft.runs_on ? { ...draft.runs_on } : null try { const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body) go({ name: "task", id: r.schedule.id }) } catch (err) { saveSaid.textContent = why(err) saveSaid.hidden = false saveBtn.disabled = false } }) clear($("scheduled-body")).append(form) drawSettings() drawWhen() words.focus() } /** * The rule set by hand, for words the server could not read into one: the days of the week (none * ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0). * `onRule(rule)` on every change; the server reads it back (preview). */ function manualRule(onRule) { const lang = document.documentElement.lang || undefined const days = Array.from({ length: 7 }, (_, i) => { const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) })) }) const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true }) const box = h( "div", { class: "sched__manual", hidden: true }, h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")), ) const changed = () => { if (!time.value) return const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value)) onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } }) } box.addEventListener("change", changed) return box } /** A field on the form's shared tracks: label, control, hint -- always all three. */ function field(label, control, hint, forId, hintId) { return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" })) } // --- Live: the list as the server's event stream gives it --------------------------------------- /** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */ function onSchedules(list) { if (!Array.isArray(list?.schedules)) return tasks = list.schedules missing = false if (!onPage() || popoverOpenHere()) return if (view.name === "list") drawList() else if (view.name === "task" && shown) { const now = tasks.find((x) => x.id === shown.id) if (!now) return go({ name: "list" }) const last = now.last_run || {} if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw() } } function popoverOpenHere() { return !!document.querySelector(".pop .smodels") } /** The palette's commands for scheduled tasks. */ export function scheduledCommands() { return [ { label: t("New scheduled task"), run: () => { ctx.show() go({ name: "form" }) }, }, ] } export function initScheduled(context) { ctx = context listen("schedules", (e) => onSchedules(e.payload)) }