diff --git a/CHANGELOG.md b/CHANGELOG.md index 072fe34..d529979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ Harness spec 3.5.0: scheduled work on a linked computer. the model runs only when the output changed. A command the schedule's mode would ask about is refused rather than run. +- **`/schedule`**: your schedules on the LLeMbas server from the terminal — the list with each + one's next run and last result, `new ` (the server works out the rule; you see what it + understood and the next runs before anything is made, and may have it run on this computer in + the current folder), `pause`, `resume`, `run` (now, without using up the next scheduled run) and + `history`. It uses this computer's sign-in; a server too old to allow that says so. + ### Changed - `team_add` under a sub-manager says where the report goes: "Its report goes to diff --git a/src/lembas/client.ts b/src/lembas/client.ts index 2782e9f..5baf1bc 100644 --- a/src/lembas/client.ts +++ b/src/lembas/client.ts @@ -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 = { 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 { diff --git a/src/lembas/schedules.ts b/src/lembas/schedules.ts new file mode 100644 index 0000000..bc91beb --- /dev/null +++ b/src/lembas/schedules.ts @@ -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 + 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 { + 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 => ((await api("GET", "/schedules"))?.schedules ?? []) as Schedule[] +export const compileWords = async (text: string): Promise => (await api("POST", "/schedules/compile", { text })) as Compiled +export const pauseSchedule = async (id: string): Promise => (await api("POST", `/schedules/${encodeURIComponent(id)}/pause`)).schedule +export const resumeSchedule = async (id: string): Promise => (await api("POST", `/schedules/${encodeURIComponent(id)}/resume`)).schedule +export const runSchedule = async (id: string): Promise => (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 { + const body: Record = { 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 (\"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 · pause|resume|run|history ") + 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 +} diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 5e78dfc..c449950 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -998,6 +998,64 @@ export const COMMANDS: Command[] = [ } }, }, + { + name: "schedule", + aliases: ["schedules"], + args: "[list | new | pause|resume|run|history ]", + 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 — 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]", diff --git a/tests/scheduled.test.ts b/tests/scheduled.test.ts index b8a3034..f3cb3bc 100644 --- a/tests/scheduled.test.ts +++ b/tests/scheduled.test.ts @@ -77,14 +77,14 @@ test("off: a scheduled session, prompt and monitor are refused with the fixed te config(fake.url) const root = project() const { c } = await client(limitsFor(root, { scheduled: false })) - const err = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } }).catch((e) => e) + const err: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => e) expect(err.message).toBe(SCHEDULED.texts.off) expect(err.data).toEqual({ reason: "scheduled_off" }) - const m = await c.request("_lembas/monitor", { cwd: root, command: "true" }).catch((e) => e) + const m: any = await c.request("_lembas/monitor", { cwd: root, command: "true" }).catch((e: any) => e) expect(m.data).toEqual({ reason: "scheduled_off" }) // An ordinary agent chat is not affected. const s: any = await c.request("session/new", { cwd: root }) - const p = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }], _meta: { lembas: { scheduled: sched() } } }).catch((e) => e) + const p: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }], _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => e) expect(p.data).toEqual({ reason: "scheduled_off" }) }) @@ -97,13 +97,13 @@ test("the mode: Edit when none chosen, Auto only when the schedule says it, Plan expect(none.modes.currentModeId).toBe("edit") const auto: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "auto" }) } } }) expect(auto.modes.currentModeId).toBe("auto") - const plan = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "plan" }) } } }).catch((e) => e) + const plan: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "plan" }) } } }).catch((e: any) => e) expect(plan.message).toBe(SCHEDULED.texts.plan) const { c: c2 } = await client(limitsFor(root, { maxMode: "manual" })) const low: any = await c2.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "auto" }) } } }) expect(low.modes.currentModeId).toBe("manual") // A folder outside the roots: the usual reason, marked. - const out = await c.request("session/new", { cwd: tmpdir(), _meta: { lembas: { scheduled: sched() } } }).catch((e) => e) + const out: any = await c.request("session/new", { cwd: tmpdir(), _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => e) expect(out.data).toEqual({ reason: "folder" }) }) @@ -160,10 +160,10 @@ test("_lembas/monitor: runs what the mode allows, refuses what it would ask abou const r: any = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "edit" }) expect(r).toMatchObject({ exit: 0, timed_out: false, excerpt: "one\n", truncated: false, bytes: 4 }) expect(r.hash).toBe(monitorHash(0, "one\n")) - const asks = await c.request("_lembas/monitor", { cwd: root, command: "rm -rf build", mode: "edit" }).catch((e) => e) + const asks: any = await c.request("_lembas/monitor", { cwd: root, command: "rm -rf build", mode: "edit" }).catch((e: any) => e) expect(asks.data).toEqual({ reason: "permission" }) expect(asks.message).toContain("edit mode") - const plan = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "plan" }).catch((e) => e) + const plan: any = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "plan" }).catch((e: any) => e) expect(plan.data).toEqual({ reason: "plan" }) }) diff --git a/tests/schedules.test.ts b/tests/schedules.test.ts new file mode 100644 index 0000000..5f7fb9d --- /dev/null +++ b/tests/schedules.test.ts @@ -0,0 +1,127 @@ +// `/schedule` over the schedule routes of /api/v2 with this device's token (harness acp.md, spec +// 3.5.0). A fake server answering with the shapes of the server's contract fixtures; the instance +// index and key are removed after each test (the suite shares one home). +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { paths } from "../src/config/paths.ts" +import { compileWords, createSchedule, findSchedule, historyLines, listLines, listSchedules, pauseSchedule, runSchedule, scheduleRuns, type Run, type Schedule } from "../src/lembas/schedules.ts" + +const RUN: Run = { + id: "run1", + status: "ok", + timing: "late", + due_at: "2026-01-02T03:04:05+00:00", + started_at: "2026-01-02T03:04:05+00:00", + finished_at: "2026-01-02T03:04:05+00:00", + waiting_for: null, + model: { name: "GPT-OSS" }, + preview: "All the waybread is wrapped.", + error: null, +} +const SCHED: Schedule = { + id: "sched1", + title: "Waybread check", + instruction: "Check the waybread is wrapped.", + description: "On the second Monday of each month at 07:30", + enabled: true, + next_run: "2026-01-12T07:30:00+00:00", + last_run: RUN, + running: false, +} +const COMPILED = { + ok: true, + reason: null, + title: "Waybread check", + instruction: "Check the waybread is wrapped.", + target: "chat", + rule: { at: { nth: [{ n: 2, weekday: 0 }], times: ["07:30"] } }, + description: "On the second Monday of each month at 07:30", + next_runs: ["2026-01-12T07:30:00+00:00", "2026-02-09T07:30:00+00:00"], + timezone: "UTC", +} + +let stop: (() => void) | undefined +const seen: { method: string; path: string; body: any }[] = [] + +function server(o: { appOnly?: boolean } = {}) { + const s = Bun.serve({ + port: 0, + async fetch(req) { + const u = new URL(req.url) + if (req.headers.get("authorization") !== "Bearer lmb_key") return new Response("no", { status: 401 }) + const body = req.method === "GET" ? null : await req.json().catch(() => null) + seen.push({ method: req.method, path: u.pathname + u.search, body }) + // An older server: /api/v2 for an app's token only. + if (o.appOnly) return Response.json({ detail: "This token does not have the app scope." }, { status: 403 }) + if (u.pathname === "/api/v2/schedules" && req.method === "GET") return Response.json({ schedules: [SCHED, { ...SCHED, id: "sched2", title: "Waybread count", enabled: false, last_run: null }] }) + if (u.pathname === "/api/v2/schedules" && req.method === "POST") return Response.json({ schedule: { ...SCHED, id: "sched3", title: body.title } }, { status: 201 }) + if (u.pathname === "/api/v2/schedules/compile") return Response.json(COMPILED) + if (u.pathname === "/api/v2/schedules/sched1/pause") return Response.json({ schedule: { ...SCHED, enabled: false } }) + if (u.pathname === "/api/v2/schedules/sched1/run") return Response.json({ run: { ...RUN, id: "run9", status: "running" } }, { status: 202 }) + if (u.pathname === "/api/v2/schedules/sched1/runs") return Response.json({ runs: [{ ...RUN, id: "run2", status: "unchanged", preview: "" }, RUN], more: true }) + return Response.json({ detail: "That schedule is not available." }, { status: 404 }) + }, + }) + stop = () => s.stop(true) + mkdirSync(join(paths.config, "lembas"), { recursive: true }) + writeFileSync(join(paths.config, "lembas", "example.key"), "lmb_key\n", { mode: 0o600 }) + writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { example: { base_url: `http://127.0.0.1:${s.port}`, connection: "example", scope: "models memory link", logged_in_at: new Date().toISOString() } } })) +} + +const clean = () => { + rmSync(join(paths.config, "lembas.json"), { force: true }) + rmSync(join(paths.config, "lembas"), { recursive: true, force: true }) + seen.length = 0 +} +beforeEach(clean) +afterEach(() => { + stop?.() + clean() +}) + +test("list, pause, run now and history go to the schedule routes with the device's token", async () => { + server() + const all = await listSchedules() + expect(all.map((s) => s.id)).toEqual(["sched1", "sched2"]) + const now = new Date("2026-01-05T12:00:00Z") + const lines = listLines(all, now) + expect(lines[0]).toBe("Waybread check (sched1) — On the second Monday of each month at 07:30") + expect(lines[1]).toContain("· last ok") + expect(lines[3]).toBe(" paused") + expect((await pauseSchedule("sched1")).enabled).toBe(false) + expect((await runSchedule("sched1")).id).toBe("run9") + const h = await scheduleRuns("sched1") + const hist = historyLines(SCHED, h.runs, h.more, now) + expect(hist[1]).toContain("unchanged (nothing new)") + expect(hist).toContain(" All the waybread is wrapped.") + expect(hist.at(-1)).toBe(" … older runs on the server") + expect(seen.map((s) => `${s.method} ${s.path}`)).toEqual(["GET /api/v2/schedules", "POST /api/v2/schedules/sched1/pause", "POST /api/v2/schedules/sched1/run", "GET /api/v2/schedules/sched1/runs?limit=10"]) +}) + +test("create from words: compile, then the rule as compiled; here: runs on this device", async () => { + server() + const c = await compileWords("every second Monday at 7:30 check the waybread") + expect(c.description).toBe("On the second Monday of each month at 07:30") + await createSchedule(c, "every second Monday at 7:30 check the waybread") + await createSchedule(c, "every second Monday", { cwd: "/work/app" }) + const posts = seen.filter((s) => s.path === "/api/v2/schedules") + expect(posts[0]!.body).toEqual({ title: "Waybread check", instruction: "Check the waybread is wrapped.", request: "every second Monday at 7:30 check the waybread", rule: COMPILED.rule, target: "chat" }) + expect(posts[1]!.body.runs_on).toEqual({ device: "this", cwd: "/work/app", mode: "edit" }) +}) + +test("an older server that lets only an app's token in: said plainly; not logged in: said", async () => { + server({ appOnly: true }) + await expect(listSchedules()).rejects.toThrow("does not let a linked computer manage schedules") + clean() + await expect(listSchedules()).rejects.toThrow("not logged in") +}) + +test("findSchedule: by id, by a title's start when only one fits, else says why", () => { + const all = [SCHED, { ...SCHED, id: "sched2", title: "Waybread count" }, { ...SCHED, id: "sched3", title: "Backup" }] + expect(findSchedule(all, "sched2")).toMatchObject({ id: "sched2" }) + expect(findSchedule(all, "back")).toMatchObject({ id: "sched3" }) + expect(findSchedule(all, "waybread")).toContain("fits 2 schedules") + expect(findSchedule(all, "nothing")).toContain("no schedule") + expect(findSchedule(all, "")).toContain("name a schedule") +})