/schedule: list, create from words (the server's compile, a preview first, optionally to run here), pause, resume, run now, history — over /api/v2 with the device's token (spec 3.5.0)
ci / check (push) Waiting to run
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+369
-7
No files matched your search
@@ -107,6 +107,16 @@ async function call(url: string, init: RequestInit, tls: Tls | undefined, timeou
|
||||
}
|
||||
}
|
||||
|
||||
/** One request to the instance with the device's token, its JSON answer and status (spec 3.5.0:
|
||||
* the schedule routes of /api/v2). A body that is not JSON is null. */
|
||||
export async function apiCall(base: string, token: string, tls: Tls | undefined, method: string, path: string, body?: unknown, timeoutMs = 30_000): Promise<{ status: number; json: any }> {
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${token}`, accept: "application/json" }
|
||||
if (body !== undefined) headers["content-type"] = "application/json"
|
||||
const res = await call(`${base}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, tls, timeoutMs)
|
||||
const json = res.status === 204 ? null : await res.json().catch(() => null)
|
||||
return { status: res.status, json }
|
||||
}
|
||||
|
||||
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
|
||||
* and a trailing slash dropped. */
|
||||
export function normalise(address: string): string {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// `/schedule`: the account's schedules on the LLeMbas server, over the schedule routes of /api/v2,
|
||||
// which accept this device's token (harness acp.md *The HTTP API*, spec 3.5.0). Scheduling is the
|
||||
// server's — the rule, the runs, the history, the notifications; this is a window onto it from the
|
||||
// terminal: list, create from words (the server's compile), pause, resume, run now, history.
|
||||
import { remoteState } from "../config/remote.ts"
|
||||
import { apiCall, LembasError, SignInRejected } from "./client.ts"
|
||||
import { soleInstance } from "./personal.ts"
|
||||
|
||||
export interface Run {
|
||||
id: string
|
||||
status: string
|
||||
timing?: string
|
||||
due_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
waiting_for?: string | null
|
||||
retry_at?: string | null
|
||||
model?: { name?: string | null } | null
|
||||
preview?: string
|
||||
error?: { class?: string; text?: string } | null
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: string
|
||||
title: string
|
||||
instruction: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
next_run?: string | null
|
||||
last_run?: Run | null
|
||||
running?: boolean
|
||||
runs_on?: { device?: string; device_name?: string; cwd?: string; mode?: string } | null
|
||||
watch?: { kind: string; url?: string; command?: string } | null
|
||||
}
|
||||
|
||||
export interface Compiled {
|
||||
ok: boolean
|
||||
reason?: string | null
|
||||
title: string
|
||||
instruction: string
|
||||
target?: string
|
||||
rule: Record<string, unknown>
|
||||
description?: string | null
|
||||
next_runs: string[]
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/** One call to the schedule routes; the server's own refusal (`detail`) as the error. */
|
||||
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const one = soleInstance()
|
||||
if (!one) throw new LembasError("not logged in to one LLeMbas server (/login)")
|
||||
const r = await apiCall(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, method, `/api/v2${path}`, body)
|
||||
const detail = typeof r.json?.detail === "string" ? r.json.detail : undefined
|
||||
if (r.status === 401 && !detail) throw new SignInRejected("the server no longer accepts this sign-in — /login again")
|
||||
// A server from before spec 3.5.0 lets only an app's token reach /api/v2.
|
||||
if (r.status === 401 || (r.status === 403 && /scope|token/i.test(detail ?? "")))
|
||||
throw new LembasError(`${one.instance.connection} does not let a linked computer manage schedules yet (it needs a newer LLeMbas server)`)
|
||||
if (r.status === 404 && !detail) throw new LembasError(`${one.instance.connection} has no schedules here (an older LLeMbas server)`)
|
||||
if (r.status >= 400) throw new LembasError(detail ?? `${method} ${path}: ${r.status}`)
|
||||
return r.json
|
||||
}
|
||||
|
||||
export const listSchedules = async (): Promise<Schedule[]> => ((await api("GET", "/schedules"))?.schedules ?? []) as Schedule[]
|
||||
export const compileWords = async (text: string): Promise<Compiled> => (await api("POST", "/schedules/compile", { text })) as Compiled
|
||||
export const pauseSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/pause`)).schedule
|
||||
export const resumeSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/resume`)).schedule
|
||||
export const runSchedule = async (id: string): Promise<Run> => (await api("POST", `/schedules/${encodeURIComponent(id)}/run`)).run
|
||||
export const scheduleRuns = async (id: string, limit = 10): Promise<{ runs: Run[]; more: boolean }> => {
|
||||
const r = await api("GET", `/schedules/${encodeURIComponent(id)}/runs?limit=${limit}`)
|
||||
return { runs: (r?.runs ?? []) as Run[], more: r?.more === true }
|
||||
}
|
||||
export const runResult = async (id: string, run: string): Promise<{ run: Run; text: string | null }> => api("GET", `/schedules/${encodeURIComponent(id)}/runs/${encodeURIComponent(run)}`)
|
||||
|
||||
/** Create one from what compile said. `here`: it runs on this computer, in `cwd`, in Edit
|
||||
* (`runs_on.device: "this"` is the calling device). */
|
||||
export async function createSchedule(c: Compiled, request: string, here?: { cwd: string }): Promise<Schedule> {
|
||||
const body: Record<string, unknown> = { title: c.title, instruction: c.instruction, request, rule: c.rule, ...(c.target ? { target: c.target } : {}) }
|
||||
if (here) body.runs_on = { device: "this", cwd: here.cwd, mode: "edit" }
|
||||
return (await api("POST", "/schedules", body)).schedule
|
||||
}
|
||||
|
||||
/** A schedule by its id, or by a title that starts with `query` (no case) when only one does. */
|
||||
export function findSchedule(all: Schedule[], query: string): Schedule | string {
|
||||
const q = query.trim()
|
||||
if (!q) return "name a schedule: its id or the start of its title (/schedule lists them)"
|
||||
const exact = all.find((s) => s.id === q)
|
||||
if (exact) return exact
|
||||
const low = q.toLowerCase()
|
||||
const titled = all.filter((s) => s.title.toLowerCase() === low)
|
||||
if (titled.length === 1) return titled[0]!
|
||||
const starts = all.filter((s) => s.title.toLowerCase().startsWith(low))
|
||||
if (starts.length === 1) return starts[0]!
|
||||
if (!starts.length) return `no schedule "${q}" — /schedule lists them`
|
||||
return `"${q}" fits ${starts.length} schedules: ${starts.map((s) => `${s.title} (${s.id})`).join(", ")} — use the id`
|
||||
}
|
||||
|
||||
/** A time as the person reads it: the local date and time, minutes only. */
|
||||
export function when(iso: string | null | undefined, now = new Date()): string {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
return sameDay ? `today ${time}` : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
|
||||
}
|
||||
|
||||
/** A run's status in a word or two: what history and the list show. */
|
||||
export function runStatus(r: Run): string {
|
||||
if (r.status === "waiting") return r.waiting_for === "device" ? "waiting for the computer" : r.waiting_for === "model" ? "waiting for a free model" : "waiting to try again"
|
||||
if (r.status === "missed") return "missed"
|
||||
if (r.status === "unchanged") return "unchanged (nothing new)"
|
||||
if (r.status === "silent") return "nothing to report"
|
||||
return r.status
|
||||
}
|
||||
|
||||
/** `/schedule` and `/schedule list`. */
|
||||
export function listLines(all: Schedule[], now = new Date()): string[] {
|
||||
if (!all.length) return ["no schedules yet — /schedule new <what, and when> (\"every weekday at 8 summarise the open issues\")"]
|
||||
const out: string[] = []
|
||||
for (const s of all) {
|
||||
const state = s.running ? "running" : s.enabled ? `next ${when(s.next_run, now)}` : "paused"
|
||||
const last = s.last_run ? ` · last ${runStatus(s.last_run)}${s.last_run.finished_at ? ` ${when(s.last_run.finished_at, now)}` : ""}` : ""
|
||||
const where = s.runs_on ? ` · on ${s.runs_on.device_name || "a computer"}${s.runs_on.cwd ? ` in ${s.runs_on.cwd}` : ""}` : ""
|
||||
const watch = s.watch ? ` · watches ${s.watch.kind === "url" ? s.watch.url : s.watch.command}` : ""
|
||||
out.push(`${s.title} (${s.id}) — ${s.description || "no rule"}`)
|
||||
out.push(` ${state}${last}${where}${watch}`)
|
||||
}
|
||||
out.push("", "/schedule new <words> · pause|resume|run|history <id or title>")
|
||||
return out
|
||||
}
|
||||
|
||||
/** `/schedule history`: the newest runs first. Previews are model output: shown as plain text. */
|
||||
export function historyLines(s: Schedule, runs: Run[], more: boolean, now = new Date()): string[] {
|
||||
const out = [`${s.title} (${s.id}) — ${s.description || "no rule"}`]
|
||||
if (!runs.length) out.push(" no runs yet")
|
||||
for (const r of runs) {
|
||||
const at = when(r.finished_at ?? r.started_at ?? r.due_at, now)
|
||||
const model = r.model?.name ? ` · ${r.model.name}` : ""
|
||||
out.push(` ${at} ${runStatus(r)}${r.timing === "late" ? " (late)" : r.timing === "catch_up" ? " (caught up)" : ""}${model} [${r.id}]`)
|
||||
const said = r.error?.text || (r.preview ?? "").replace(/\s+/g, " ").trim()
|
||||
if (said) out.push(` ${said.length > 160 ? said.slice(0, 159) + "…" : said}`)
|
||||
}
|
||||
if (more) out.push(" … older runs on the server")
|
||||
return out
|
||||
}
|
||||
|
||||
/** What compile made of the words, before anything is created. */
|
||||
export function previewLines(c: Compiled, now = new Date()): string[] {
|
||||
const out = [`${c.title || "(no title)"} — ${c.description || "no rule worked out"}`]
|
||||
if (c.instruction) out.push(` does: ${c.instruction}`)
|
||||
if (c.next_runs.length) out.push(` next: ${c.next_runs.map((t) => when(t, now)).join(", ")}${c.timezone ? ` (${c.timezone})` : ""}`)
|
||||
if (!c.ok && c.reason) out.push(` ${c.reason}`)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether a schedule could run here, in `cwd`: Accept scheduled work is on and remote work may use it. */
|
||||
export function canRunHere(): boolean {
|
||||
const s = remoteState()
|
||||
return s.enabled && s.scheduled && s.roots.length > 0
|
||||
}
|
||||
@@ -998,6 +998,64 @@ export const COMMANDS: Command[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schedule",
|
||||
aliases: ["schedules"],
|
||||
args: "[list | new <words> | pause|resume|run|history <id or title>]",
|
||||
description: "your schedules on the LLeMbas server: list, create from words, pause, resume, run now, history",
|
||||
async run(c, arg) {
|
||||
const S = await import("../lembas/schedules.ts")
|
||||
const [sub = "list", ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||
const words = arg.trim().slice(sub.length).trim()
|
||||
const say = (lines: string[], level?: "warn") => c.view.info(lines.join("\n"), level)
|
||||
try {
|
||||
if (sub === "list" || sub === "ls") return say(S.listLines(await S.listSchedules()))
|
||||
if (sub === "new" || sub === "create" || sub === "add") {
|
||||
if (!words) return say(["usage: /schedule new <what, and when> — e.g. /schedule new every weekday at 8:30 summarise the open issues"], "warn")
|
||||
c.view.info("working out what that means…")
|
||||
const compiled = await S.compileWords(words)
|
||||
const preview = S.previewLines(compiled)
|
||||
if (!Object.keys(compiled.rule ?? {}).length) return say([...preview, "", "No schedule made: say when it should run (\"every Monday at 9\", \"tomorrow at 7\")."], "warn")
|
||||
const cwd = c.app.engine.o.toolCtx.cwd
|
||||
const { coveredBy, remoteState } = await import("../config/remote.ts")
|
||||
const here = S.canRunHere() && coveredBy(cwd, remoteState().roots) !== undefined
|
||||
c.openDialog({
|
||||
title: preview[0]!,
|
||||
options: [
|
||||
{ label: "Create it", value: "server", description: preview.slice(1).join(" · ") || "runs on the server" },
|
||||
...(here ? [{ label: "Create it, to run on this computer", value: "here", description: `as an agent turn in ${cwd}, in Edit` }] : []),
|
||||
{ label: "Cancel", value: "cancel" },
|
||||
],
|
||||
onSelect: (v) => {
|
||||
if (v === "cancel") return c.view.info("no schedule made")
|
||||
void S.createSchedule(compiled, words, v === "here" ? { cwd } : undefined)
|
||||
.then((made) => c.view.info(`made ${made.title} (${made.id}) — next ${S.when(made.next_run)}`))
|
||||
.catch((e) => c.view.info(`/schedule: ${(e as Error).message}`, "warn"))
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (["pause", "resume", "run", "history", "runs"].includes(sub)) {
|
||||
const found = S.findSchedule(await S.listSchedules(), rest.join(" "))
|
||||
if (typeof found === "string") return say([found], "warn")
|
||||
if (sub === "pause") return say([`paused ${(await S.pauseSchedule(found.id)).title}`])
|
||||
if (sub === "resume") {
|
||||
const s = await S.resumeSchedule(found.id)
|
||||
return say([`resumed ${s.title} — next ${S.when(s.next_run)}`])
|
||||
}
|
||||
if (sub === "run") {
|
||||
const r = await S.runSchedule(found.id)
|
||||
return say([`${found.title}: started now (${r.id}) — the next scheduled run stays as it was; /schedule history ${found.id}`])
|
||||
}
|
||||
const h = await S.scheduleRuns(found.id)
|
||||
return say(S.historyLines(found, h.runs, h.more))
|
||||
}
|
||||
say([`usage: /schedule ${COMMANDS.find((x) => x.name === "schedule")!.args}`], "warn")
|
||||
} catch (e) {
|
||||
say([`/schedule: ${(e as Error).message}`], "warn")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logout",
|
||||
args: "[connection]",
|
||||
|
||||
Reference in new issue
Block a user