From c840868c3fd9e635472ced69162d6cd7c623c78e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:49:03 -0300 Subject: [PATCH] feat(routines): bots change and delete their own routines, with the owner's ok my_routines lists a bot's routines; change_routine and delete_routine ask the owner in the chat, like schedule_routine does. The change card shows what changes, before and after, and the owner can adjust it before allowing it; the delete card shows why. A bot that bypasses permissions does it at once. --- app/src/features/chat/ApprovalCard.tsx | 10 + app/src/features/chat/ToolLines.tsx | 3 + .../features/routines/RoutineChangeCard.tsx | 289 +++++++++++++++++ .../features/routines/routineChange.test.tsx | 111 +++++++ app/src/i18n/en/chat.ts | 36 ++ app/src/i18n/en/tools.ts | 3 + app/src/i18n/es/chat.ts | 34 ++ app/src/i18n/es/tools.ts | 3 + app/src/i18n/pt-BR/chat.ts | 34 ++ app/src/i18n/pt-BR/tools.ts | 3 + crates/botloft-core/src/chat.rs | 10 +- crates/botloftd/src/approvals/mod.rs | 7 +- crates/botloftd/src/tools/calls.rs | 2 + crates/botloftd/src/tools/catalog.rs | 11 + crates/botloftd/src/tools/mod.rs | 13 + crates/botloftd/src/tools/routine_catalog.rs | 76 +++++ crates/botloftd/src/tools/routine_change.rs | 307 ++++++++++++++++++ crates/botloftd/src/workspace/files.rs | 5 +- crates/botloftd/tests/mcp.rs | 5 +- crates/botloftd/tests/routine_changes.rs | 164 ++++++++++ docs/spec.md | 5 +- 21 files changed, 1125 insertions(+), 6 deletions(-) create mode 100644 app/src/features/routines/RoutineChangeCard.tsx create mode 100644 app/src/features/routines/routineChange.test.tsx create mode 100644 crates/botloftd/src/tools/routine_catalog.rs create mode 100644 crates/botloftd/src/tools/routine_change.rs create mode 100644 crates/botloftd/tests/routine_changes.rs diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index 1536d882..0ddd5bc5 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -9,6 +9,12 @@ import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard"; import { Details } from "../../ui/Details"; import { HELP_TOOL, HelpCard } from "../browser/HelpCard"; import { SITE_TOOL, SiteCard } from "../browser/SiteCard"; +import { + CHANGE_ROUTINE_TOOL, + DELETE_ROUTINE_TOOL, + RoutineChangeCard, + RoutineDeleteCard, +} from "../routines/RoutineChangeCard"; import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; @@ -62,6 +68,10 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B return ; case ROUTINE_TOOL: return ; + case CHANGE_ROUTINE_TOOL: + return ; + case DELETE_ROUTINE_TOOL: + return ; case SITE_TOOL: return ; case HELP_TOOL: diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 66e52bd2..cbaf3d6b 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -54,6 +54,9 @@ const ICONS: Record = { send_message: Send, suggest_bot: UserPlus, schedule_routine: AlarmClock, + my_routines: AlarmClock, + change_routine: AlarmClock, + delete_routine: AlarmClock, browser_ask_owner: Hand, }; diff --git a/app/src/features/routines/RoutineChangeCard.tsx b/app/src/features/routines/RoutineChangeCard.tsx new file mode 100644 index 00000000..8ebca71a --- /dev/null +++ b/app/src/features/routines/RoutineChangeCard.tsx @@ -0,0 +1,289 @@ +// A bot asking to change or delete one of its routines (spec 20.12). A +// change shows what changes, before and after, and the owner can adjust it +// before allowing it; a deletion shows why. Either can be refused with a +// note for the bot. Answered, each shrinks to one line. + +import { AlarmClock, Ban, Check, Pencil, TimerOff, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { type ApprovalItem, type Bot, FIELD_LIMITS, type Schedule } from "../../lib/protocol.gen"; +import { RpcError } from "../../lib/rpc"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; +import { TextArea, TextField } from "../../ui/Field"; +import { Switch } from "../../ui/Switch"; +import { describeSchedule, zoneLabel } from "./describe"; +import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form"; +import { MoreOptions } from "./MoreOptions"; +import { ScheduleFields } from "./ScheduleFields"; + +export const CHANGE_ROUTINE_TOOL = "mcp__botloft__change_routine"; +export const DELETE_ROUTINE_TOOL = "mcp__botloft__delete_routine"; + +type Changed = RoutineFields & { routine_id: string; enabled: boolean }; + +interface Asked { + name: string; + before: Changed | null; + after: Changed | null; +} + +function parsed(input: string): Partial { + try { + return JSON.parse(input) as Partial; + } catch { + return {}; + } +} + +/** One thing that changes, before and after, in words. */ +function useChanges(before: Changed, after: Changed): [string, string, string][] { + const t = useT(); + const w = t.chat.routineChange; + const when = (schedule: Schedule) => describeSchedule(schedule, t.routines.when); + const onOff = (on: boolean) => (on ? w.on : w.off); + const rows: [string, string, string][] = []; + if (before.name !== after.name) rows.push([t.routines.dialog.name, before.name, after.name]); + if (JSON.stringify(before.schedule) !== JSON.stringify(after.schedule)) { + rows.push([t.routines.dialog.when, when(before.schedule), when(after.schedule)]); + } + if (before.timezone !== after.timezone) { + rows.push([w.timezone, zoneLabel(before.timezone), zoneLabel(after.timezone)]); + } + if (before.enabled !== after.enabled) { + rows.push([w.state, onOff(before.enabled), onOff(after.enabled)]); + } + if (before.overlap !== after.overlap || before.missed !== after.missed) { + rows.push([w.options, w.optionsBefore, w.optionsAfter]); + } + return rows; +} + +function answerError(failure: unknown, reasons: Record, fallback: string): string { + const reason = failure instanceof RpcError ? failure.reason : undefined; + return reason && reason in reasons + ? (reasons[reason] as string) + : `${fallback}: ${errorText(failure)}`; +} + +export function RoutineChangeCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const w = t.chat.routineChange; + const d = t.routines.dialog; + const api = useApi(); + const asked = parsed(approval.input); + const before = asked.before ?? null; + const after = asked.after ?? null; + const [form, setForm] = useState(null); + const [enabled, setEnabled] = useState(after?.enabled ?? true); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const changes = useChanges(before ?? (after as Changed), after ?? (before as Changed)); + if (approval.status !== "pending" || !before || !after) { + return ; + } + const change = (patch: Partial) => + setForm((current) => (current ? { ...current, ...patch } : current)); + + const answer = async (allow: boolean) => { + setBusy(true); + setError(null); + const adjusted: Changed | null = form + ? { + routine_id: after.routine_id, + name: form.name, + prompt: form.prompt, + schedule: scheduleOf(form), + timezone: form.timezone, + overlap: form.overlap, + missed: form.missed, + enabled, + } + : null; + const trimmed = note.trim(); + try { + await api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(!allow && trimmed ? { note: trimmed } : {}), + ...(allow && adjusted ? { input: JSON.stringify(adjusted) } : {}), + }); + } catch (failure) { + setError(answerError(failure, d.reasons, allow ? w.applyFailed : w.declineFailed)); + } + setBusy(false); + }; + + return ( + + +
+ + +
+ + } + > +
+ {changes.length > 0 && ( +
+ {changes.map(([what, was, becomes]) => ( +
+
{what}
+
+ {was} + {" → "} + {becomes} +
+
+ ))} +
+ )} + {before.prompt !== after.prompt && ( +
+ {w.newPrompt} +

+ {after.prompt} +

+
+ )} + {form ? ( +
+ change({ name: event.target.value })} + /> +