diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index d92d78c4..8ab746ca 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -1,34 +1,25 @@ import { - Archive, - Crown, Ellipsis, Files, - FolderOpen, Globe, LayoutTemplate, PanelRight, Pause, - Pencil, Play, - RefreshCcw, RotateCw, ShieldOff, } from "lucide-react"; -import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; -import { useApi, useApp, useHost } from "../../store/context"; +import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotAvatar, moodOf } from "./BotAvatar"; -import { BotDialog } from "./BotDialog"; import { BotStateBadge } from "./BotStateBadge"; +import { useBotActions } from "./botActions"; import { ChiefBadge, isChief } from "./ChiefBadge"; -type Open = "edit" | "archive" | "fresh" | null; - /** The bot's name, state and actions. */ export function BotHeader({ bot, @@ -68,25 +59,18 @@ export function BotHeader({ const t = useT(); const words = t.bots.header; const api = useApi(); - const host = useHost(); const putBot = useApp((state) => state.putBot); - const putCrew = useApp((state) => state.putCrew); const chief = isChief(bot, crew); - const [open, setOpen] = useState(null); - const close = () => setOpen(null); + const actions = useBotActions(bot, crew); const stopped = bot.paused || crew.paused; const setPaused = (paused: boolean) => attempt(paused ? words.failed.pause : words.failed.resume, async () => putBot(await api.call("bots.setPaused", { botId: bot.id, paused })), ); - const setChief = (on: boolean) => - attempt(words.failed.chief, async () => - putCrew(await api.call("crews.setLead", { crewId: crew.id, botId: on ? bot.id : null })), - ); - const restart = (fresh: boolean) => + const restart = () => attempt(words.failed.restart, async () => - putBot(await api.call("bots.restart", { botId: bot.id, fresh })), + putBot(await api.call("bots.restart", { botId: bot.id, fresh: false })), ); return ( @@ -123,7 +107,7 @@ export function BotHeader({ {words.pause} )} - @@ -202,56 +186,8 @@ export function BotHeader({ aria-pressed={detailsOpen} onClick={onToggleDetails} /> - setOpen("edit") }, - { - label: chief ? words.stopChief : words.makeChief, - icon: Crown, - onSelect: () => setChief(!chief), - }, - { - label: words.restartFresh, - icon: RefreshCcw, - disabled: stopped, - onSelect: () => setOpen("fresh"), - }, - { - label: words.openFolder, - icon: FolderOpen, - onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), - }, - { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, - ]} - /> - - {open === "edit" && } - {open === "fresh" && ( - restart(true)} - > - {words.fresh.body(bot.name)} - - )} - {open === "archive" && ( - - attempt(words.failed.archive, async () => - putBot(await api.call("bots.archive", { botId: bot.id })), - ) - } - > - {words.archiveConfirm.body} - - )} + + {actions.dialogs} ); } diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx new file mode 100644 index 00000000..86b33021 --- /dev/null +++ b/app/src/features/bots/botActions.tsx @@ -0,0 +1,111 @@ +import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot, Crew } from "../../lib/protocol.gen"; +import { useApi, useApp, useHost } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; +import type { MenuItem } from "../../ui/Menu"; +import { attempt } from "../../ui/toast"; +import { BotDialog } from "./BotDialog"; +import { isChief } from "./ChiefBadge"; + +type Open = "edit" | "archive" | "delete" | "fresh" | null; + +/** + * What the owner can do with a bot from a menu, and the dialogs those + * actions open (`null` while none is). The header's menu and the right-click + * menu of the conversation list both come from here, so they stay the same. + */ +export function useBotActions( + bot: Bot, + crew: Crew, +): { items: MenuItem[]; dialogs: ReactNode | null } { + const t = useT(); + const words = t.bots.header; + const api = useApi(); + const host = useHost(); + const putBot = useApp((state) => state.putBot); + const putCrew = useApp((state) => state.putCrew); + const dropBot = useApp((state) => state.dropBot); + const chief = isChief(bot, crew); + const [open, setOpen] = useState(null); + const close = () => setOpen(null); + + const items: MenuItem[] = [ + { label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") }, + { + label: chief ? words.stopChief : words.makeChief, + icon: Crown, + onSelect: () => + attempt(words.failed.chief, async () => + putCrew( + await api.call("crews.setLead", { crewId: crew.id, botId: chief ? null : bot.id }), + ), + ), + }, + { + label: words.restartFresh, + icon: RefreshCcw, + disabled: bot.paused || crew.paused, + onSelect: () => setOpen("fresh"), + }, + { + label: words.openFolder, + icon: FolderOpen, + onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), + }, + { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, + ]; + + const dialogs = + open === "edit" ? ( + + ) : open === "fresh" ? ( + + attempt(words.failed.restart, async () => + putBot(await api.call("bots.restart", { botId: bot.id, fresh: true })), + ) + } + > + {words.fresh.body(bot.name)} + + ) : open === "archive" ? ( + + attempt(words.failed.archive, async () => + putBot(await api.call("bots.archive", { botId: bot.id })), + ) + } + > + {words.archiveConfirm.body} + + ) : open === "delete" ? ( + + attempt(words.failed.delete, async () => + dropBot((await api.call("bots.delete", { botId: bot.id })).botId), + ) + } + > +

+ {words.deleteConfirm.removed(bot.name)} + {chief && ` ${words.deleteConfirm.chief(crew.name)}`} +

+ {words.deleteConfirm.kept(bot.name)} +
+ ) : null; + + return { items, dialogs }; +} diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx new file mode 100644 index 00000000..a3ba782c --- /dev/null +++ b/app/src/features/bots/delete.test.tsx @@ -0,0 +1,163 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { RpcError } from "../../lib/rpc"; +import { crewOpened, openBot, openTab, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + renderApp(fake); + await crewOpened("Ops"); + return { fake, crew, scout, writer }; +} + +const inSidebar = (name: string) => + within(sidebar()).queryByRole("button", { name: new RegExp(name) }); + +/** Picks Delete in the open bot's menu and returns the confirmation. */ +function askToDelete(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete bot" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); +} + +describe("deleting a bot", () => { + test("says what goes and what stays, then removes the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain( + "Scout stops now and leaves Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.", + ); + expect(dialog.textContent).toContain("Scout's folder stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + expect(dialog.textContent).not.toContain("without a chief"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + // Back on the crew's page, without the bot. + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(fake.calls.at(-1)).toEqual({ method: "bots.delete", params: { botId: scout.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Writer")).not.toBeNull(); + }); + + test("cancelling keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(fake.bots.has(scout.id)).toBe(true); + expect(fake.calls.some((call) => call.method === "bots.delete")).toBe(false); + }); + + test("works from the right-click menu, for a bot that is not open", async () => { + const { fake, scout } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + fireEvent.contextMenu(inSidebar("Scout") as HTMLElement, { clientX: 80, clientY: 120 }); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + // Delete comes last, after Archive. + expect(within(menu).getAllByRole("menuitem").at(-1)?.textContent).toBe("Delete bot"); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Delete bot" })); + const dialog = screen.getByRole("dialog", { name: "Delete Scout?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.bots.has(scout.id)).toBe(false); + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + }); + + test("warns that the crew loses its chief", async () => { + const { fake, crew, scout } = await twoBots(); + await fake.call("crews.setLead", { crewId: crew.id, botId: scout.id }); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain("Ops will be left without a chief."); + }); + + test("a delete that fails says so and keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fake.failNext("bots.delete", new RpcError(-32603, "internal error")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await screen.findByText(/Could not delete the bot/); + expect(fake.bots.has(scout.id)).toBe(true); + expect(inSidebar("Scout")).not.toBeNull(); + }); + + test("what it sent stays in the timeline, from a bot no longer in the crew", async () => { + const { fake, scout, writer } = await twoBots(); + fake.conversation.say({ from: scout.id, to: writer.id, body: "The draft is in shared/." }); + await fake.call("bots.delete", { botId: scout.id }); + openTab("Timeline"); + const timeline = screen.getByRole("tabpanel", { name: "Timeline" }); + const message = (await within(timeline).findByText("The draft is in shared/.")).closest("li"); + expect(message?.textContent).toContain("a bot no longer in the crew"); + }); +}); + +describe("deleting a crew", () => { + function askToDeleteCrew(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); + } + + test("says what goes and what stays, then removes the crew and its bots", async () => { + const { fake, crew, scout } = await twoBots(); + const docs = fake.addCrew("Docs"); + await waitFor(() => + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(), + ); + const dialog = askToDeleteCrew("Ops"); + expect(dialog.textContent).toContain( + "Ops and its 2 bots stop now and leave Botloft for good, with their conversations, routines and tasks. This can't be undone.", + ); + expect(dialog.textContent).toContain("The folders stay on your computer"); + expect(within(dialog).getByText(crew.workFolder)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Ops/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "crews.delete", params: { crewId: crew.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Scout")).toBeNull(); + expect(fake.crews.has(docs.id)).toBe(true); + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(); + }); + + test("an empty crew and a crew of one read right", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const empty = askToDeleteCrew("Ops"); + expect(empty.textContent).toContain("Ops leaves Botloft for good. This can't be undone."); + fireEvent.click(within(empty).getByRole("button", { name: "Cancel" })); + + fake.addBot(crew.id, "Scout"); + await waitFor(() => expect(inSidebar("Scout")).not.toBeNull()); + expect(askToDeleteCrew("Ops").textContent).toContain("Ops and its bot stop now"); + }); + + test("the last crew gone, the app is back at the start", async () => { + const { fake } = await twoBots(); + const dialog = askToDeleteCrew("Ops"); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); + expect(fake.crews.size).toBe(0); + }); +}); diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 7858cc15..20bceda3 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -71,7 +71,7 @@ function OtherMessage({ message }: { message: Message }) { const sender = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined)); const delivery = useApp((state) => state.deliveries[message.id]); const system = message.fromKind === "system"; - const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); + const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.goneBot); const arrival = useArrival(message.createdAt); return (
  • diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index bc53019c..65f6a413 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -8,6 +8,7 @@ import { Pencil, Play, Plus, + Trash2, } from "lucide-react"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -17,6 +18,7 @@ import { botsOf } from "../../store/app"; import { useApi, useApp, useHost } from "../../store/context"; import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; import { Menu } from "../../ui/Menu"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { attempt } from "../../ui/toast"; @@ -28,7 +30,7 @@ import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; import { CrewDialog } from "./CrewDialog"; -type Open = "bot" | "rename" | "archive" | { move: string } | null; +type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; type Pane = "bots" | "timeline" | "tasks" | "routines"; export function CrewView({ crew }: { crew: Crew }) { @@ -37,6 +39,7 @@ export function CrewView({ crew }: { crew: Crew }) { const api = useApi(); const host = useHost(); const putCrew = useApp((state) => state.putCrew); + const dropCrew = useApp((state) => state.dropCrew); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); const [open, setOpen] = useState(null); const [pane, setPane] = useState("bots"); @@ -109,6 +112,7 @@ export function CrewView({ crew }: { crew: Crew }) { danger: true, onSelect: () => setOpen("archive"), }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]} /> @@ -188,6 +192,21 @@ export function CrewView({ crew }: { crew: Crew }) { {words.archiveBody(bots.length)} )} + {open === "delete" && ( + + attempt(words.failed.delete, async () => + dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId), + ) + } + > +

    {words.deleteBody(crew.name, bots.length)}

    + {words.deleteKept} +
    + )} ); } diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 73883475..3dd85325 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,5 +1,6 @@ import { Pause, Plus } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; +import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; import { type Messages, useT } from "../../i18n"; import { when } from "../../lib/format"; @@ -7,10 +8,12 @@ import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; import { AccountArea } from "../account/AccountArea"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; +import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; @@ -85,6 +88,10 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const selected = useApp((state) => state.selectedBotId === bot.id); const selectBot = useApp((state) => state.selectBot); + // A right-click opens the bot's menu, the same as in its header. + const actions = useBotActions(bot, crew); + const [menuAt, setMenuAt] = useState(null); + const closeMenu = useCallback(() => setMenuAt(null), []); const activity = bot.lastActivity; return (
  • APP_OPENED ? "animate-rise" : undefined}> @@ -93,7 +100,11 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { aria-current={selected ? "page" : undefined} aria-label={`${bot.name}, ${stateView(bot, crew.paused).label}`} onClick={() => selectBot(bot.id)} - className={`${row} gap-2.5 px-2.5 py-2 ${selected ? "bg-sunken" : ""}`} + onContextMenu={(event) => { + event.preventDefault(); + setMenuAt(menuPoint(event)); + }} + className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`} > @@ -119,6 +130,16 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { + {menuAt && ( + + )} + {/* Outside the list: a dialog is not part of the navigation. */} + {actions.dialogs && createPortal(actions.dialogs, document.body)}
  • ); } diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx new file mode 100644 index 00000000..a8be2fb9 --- /dev/null +++ b/app/src/features/crews/botMenu.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + const { host } = renderApp(fake); + await crewOpened("Ops"); + return { fake, host, crew, scout, writer }; +} + +const row = (name: string) => within(sidebar()).getByRole("button", { name: new RegExp(name) }); + +/** Right-clicks the bot's conversation and returns the menu that opens. */ +function rightClick(name: string) { + fireEvent.contextMenu(row(name), { clientX: 120, clientY: 200 }); + return screen.getByRole("menu", { name: `Actions for ${name}` }); +} + +const labels = (menu: HTMLElement) => + within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent); + +describe("a bot's right-click menu", () => { + test("offers what the header's menu offers, without opening the bot", async () => { + await twoBots(); + const menu = rightClick("Scout"); + const offered = labels(menu); + expect(offered).toEqual([ + "Edit", + "Make crew chief", + "Restart with a new conversation", + "Open folder", + "Archive bot", + "Delete bot", + ]); + // The crew's page is still the one open. + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(menu.style.left).toBe("120px"); + expect(menu.style.top).toBe("200px"); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + openBot("Scout"); + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + expect(labels(screen.getByRole("menu"))).toEqual(offered); + }); + + test("acts on the bot that was clicked, not the one that is open", async () => { + const { fake, host, crew, scout, writer } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Open folder" })); + expect(host.opened).toEqual([scout.workspace]); + expect(screen.queryByRole("menu")).toBeNull(); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Make crew chief" })); + await waitFor(() => expect(fake.crews.get(crew.id)?.leadBotId).toBe(scout.id)); + expect(labels(rightClick("Scout"))).toContain("Stop being chief"); + expect(labels(rightClick("Writer"))).toContain("Make crew chief"); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Archive bot" })); + const dialog = screen.getByRole("dialog", { name: "Archive Scout?" }); + expect(sidebar().contains(dialog)).toBe(false); + fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "bots.archive", params: { botId: scout.id } }); + // Writer stays open through it all. + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + expect(fake.bots.get(writer.id)?.archivedAt).toBeNull(); + }); + + test("edits from the menu and keeps a paused bot from restarting", async () => { + const { fake, scout } = await twoBots(); + await fake.call("bots.setPaused", { botId: scout.id, paused: true }); + const menu = rightClick("Scout"); + const fresh = within(menu).getByRole("menuitem", { name: "Restart with a new conversation" }); + expect((fresh as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Edit" })); + expect(screen.getByRole("dialog", { name: "Edit Scout" })).toBeDefined(); + }); + + test("opens from the keyboard and walks with the arrows", async () => { + await twoBots(); + const scout = row("Scout"); + scout.focus(); + // The menu key sends a context menu event without a pointer. + fireEvent.contextMenu(scout); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + const items = within(menu).getAllByRole("menuitem"); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(items.at(-1)); + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(items[0]); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(document.activeElement).toBe(scout); + }); + + test("closes on a click elsewhere, and another bot's menu takes its place", async () => { + await twoBots(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(screen.queryByRole("menu")).toBeNull(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(rightClick("Writer")).toBeDefined(); + expect(screen.getAllByRole("menu")).toHaveLength(1); + }); +}); diff --git a/app/src/features/messages/MessageRow.tsx b/app/src/features/messages/MessageRow.tsx index 08075b3d..0db72590 100644 --- a/app/src/features/messages/MessageRow.tsx +++ b/app/src/features/messages/MessageRow.tsx @@ -9,7 +9,7 @@ import { DeliveryStatus } from "./DeliveryStatus"; function BotName({ bot }: { bot: Bot | undefined }) { const t = useT(); if (!bot) { - return {t.messages.row.archivedBot}; + return {t.messages.row.goneBot}; } return ( diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 14d5b269..febb9a04 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -41,12 +41,14 @@ export const bots = { showDetails: "Show details", hideDetails: "Hide details", more: "More bot actions", + menuOf: (name: string) => `Actions for ${name}`, edit: "Edit", restartFresh: "Restart with a new conversation", openFolder: "Open folder", makeChief: "Make crew chief", stopChief: "Stop being chief", archive: "Archive bot", + delete: "Delete bot", failed: { pause: "Could not pause the bot", resume: "Could not resume the bot", @@ -54,6 +56,7 @@ export const bots = { openFolder: "Could not open the folder", chief: "Could not change the chief", archive: "Could not archive the bot", + delete: "Could not delete the bot", }, fresh: { title: "Start a new conversation?", @@ -66,6 +69,14 @@ export const bots = { confirm: "Archive bot", body: "The bot stops and leaves the crew. Messages still waiting for it are not delivered.", }, + deleteConfirm: { + title: (name: string) => `Delete ${name}?`, + confirm: "Delete bot", + removed: (name: string) => + `${name} stops now and leaves Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.`, + chief: (crew: string) => `${crew} will be left without a chief.`, + kept: (name: string) => `${name}'s folder stays on your computer, with everything in it:`, + }, }, dialog: { newTitle: "New bot", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 30ef29c9..f719d9ce 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -43,7 +43,7 @@ export const chat = { inbound: { task: "Task", result: "Result", - archivedBot: "An archived bot", + goneBot: "A bot no longer in the crew", }, run: { working: "Working", diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 0ea025b5..45fc6940 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -49,6 +49,7 @@ export const crews = { `The bots will work in ${path}. Each one restarts when it finishes what it's doing. Files already made stay where they are.`, move: "Move", archive: "Archive crew", + delete: "Delete crew", tabs: { label: "Crew views", bots: "Bots", @@ -62,12 +63,20 @@ export const crews = { bots === 0 ? "The crew leaves the app." : `The crew leaves the app. ${bots === 1 ? "Its bot stops" : `Its ${bots} bots stop`}, and messages still waiting for them are not delivered.`, + deleteTitle: (crew: string) => `Delete ${crew}?`, + deleteBody: (crew: string, bots: number) => + bots === 0 + ? `${crew} leaves Botloft for good. This can't be undone.` + : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} stop now and leave Botloft for good, with their conversations, routines and tasks. This can't be undone.`, + deleteKept: + "The folders stay on your computer, with everything in them: each bot's own folder and the crew's work folder:", failed: { pause: "Could not pause the crew", resume: "Could not resume the crew", changeFolder: "Could not change the folder", openFolder: "Could not open the folder", archive: "Could not archive the crew", + delete: "Could not delete the crew", }, }, bots: { diff --git a/app/src/i18n/en/messages.ts b/app/src/i18n/en/messages.ts index 19bdafd4..01c2b7a7 100644 --- a/app/src/i18n/en/messages.ts +++ b/app/src/i18n/en/messages.ts @@ -33,7 +33,7 @@ export const messages = { row: { you: "You", system: "Botloft", - archivedBot: "an archived bot", + goneBot: "a bot no longer in the crew", to: "to", task: "Task", result: "Result", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index b4dcfab9..e1d45944 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -37,12 +37,14 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalles", hideDetails: "Ocultar detalles", more: "Más acciones del bot", + menuOf: (name: string) => `Acciones de ${name}`, edit: "Editar", restartFresh: "Reiniciar con una conversación nueva", openFolder: "Abrir carpeta", makeChief: "Hacer jefe del equipo", stopChief: "Dejar de ser jefe", archive: "Archivar bot", + delete: "Eliminar bot", failed: { pause: "No se pudo pausar el bot", resume: "No se pudo reanudar el bot", @@ -50,6 +52,7 @@ export const bots: Messages["bots"] = { openFolder: "No se pudo abrir la carpeta", chief: "No se pudo cambiar el jefe", archive: "No se pudo archivar el bot", + delete: "No se pudo eliminar el bot", }, fresh: { title: "¿Empezar una conversación nueva?", @@ -62,6 +65,15 @@ export const bots: Messages["bots"] = { confirm: "Archivar bot", body: "El bot se detiene y sale del equipo. Los mensajes que aún lo esperan no se entregan.", }, + deleteConfirm: { + title: (name: string) => `¿Eliminar ${name}?`, + confirm: "Eliminar bot", + removed: (name: string) => + `${name} se detiene ahora y sale de Botloft para siempre, con su conversación, sus rutinas y las tareas en las que participaba. No se puede deshacer.`, + chief: (crew: string) => `${crew} se queda sin jefe.`, + kept: (name: string) => + `La carpeta de ${name} sigue en tu ordenador, con todo lo que contiene:`, + }, }, dialog: { newTitle: "Nuevo bot", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index a69002a1..6fcc7b2e 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -44,7 +44,7 @@ export const chat: Messages["chat"] = { inbound: { task: "Tarea", result: "Resultado", - archivedBot: "Un bot archivado", + goneBot: "Un bot que ya no está en el equipo", }, run: { working: "Trabajando", diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index b5b164f4..45c2a0cb 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -46,6 +46,7 @@ export const crews: Messages["crews"] = { `Los bots trabajarán en ${path}. Cada uno se reinicia cuando termine lo que está haciendo. Lo que ya hicieron se queda donde está.`, move: "Mover", archive: "Archivar equipo", + delete: "Eliminar equipo", tabs: { label: "Vistas del equipo", bots: "Bots", @@ -59,12 +60,20 @@ export const crews: Messages["crews"] = { bots === 0 ? "El equipo sale de la app." : `El equipo sale de la app. ${bots === 1 ? "Su bot se detiene" : `Sus ${bots} bots se detienen`}, y los mensajes que aún ${bots === 1 ? "lo" : "los"} esperan no se entregan.`, + deleteTitle: (crew: string) => `¿Eliminar ${crew}?`, + deleteBody: (crew: string, bots: number) => + bots === 0 + ? `${crew} sale de Botloft para siempre. No se puede deshacer.` + : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} se detienen ahora y salen de Botloft para siempre, con sus conversaciones, rutinas y tareas. No se puede deshacer.`, + deleteKept: + "Las carpetas siguen en tu ordenador, con todo lo que contienen: la carpeta de cada bot y la carpeta de trabajo del equipo:", failed: { pause: "No se pudo pausar el equipo", resume: "No se pudo reanudar el equipo", changeFolder: "No se pudo cambiar la carpeta", openFolder: "No se pudo abrir la carpeta", archive: "No se pudo archivar el equipo", + delete: "No se pudo eliminar el equipo", }, }, bots: { diff --git a/app/src/i18n/es/messages.ts b/app/src/i18n/es/messages.ts index b210d238..cbfdf4f1 100644 --- a/app/src/i18n/es/messages.ts +++ b/app/src/i18n/es/messages.ts @@ -31,7 +31,7 @@ export const messages: Messages["messages"] = { row: { you: "Tú", system: "Botloft", - archivedBot: "un bot archivado", + goneBot: "un bot que ya no está en el equipo", to: "para", task: "Tarea", result: "Resultado", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 9cad8fc3..6077acef 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -37,12 +37,14 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalhes", hideDetails: "Ocultar detalhes", more: "Mais ações do bot", + menuOf: (name: string) => `Ações de ${name}`, edit: "Editar", restartFresh: "Reiniciar com uma nova conversa", openFolder: "Abrir pasta", makeChief: "Tornar chefe da equipe", stopChief: "Deixar de ser chefe", archive: "Arquivar bot", + delete: "Excluir bot", failed: { pause: "Não foi possível pausar o bot", resume: "Não foi possível retomar o bot", @@ -50,6 +52,7 @@ export const bots: Messages["bots"] = { openFolder: "Não foi possível abrir a pasta", chief: "Não foi possível trocar o chefe", archive: "Não foi possível arquivar o bot", + delete: "Não foi possível excluir o bot", }, fresh: { title: "Começar uma nova conversa?", @@ -62,6 +65,15 @@ export const bots: Messages["bots"] = { confirm: "Arquivar bot", body: "O bot para e sai da equipe. As mensagens que ainda esperam por ele não são entregues.", }, + deleteConfirm: { + title: (name: string) => `Excluir ${name}?`, + confirm: "Excluir bot", + removed: (name: string) => + `${name} para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte. Não dá para desfazer.`, + chief: (crew: string) => `${crew} fica sem chefe.`, + kept: (name: string) => + `A pasta de ${name} continua no seu computador, com tudo o que está nela:`, + }, }, dialog: { newTitle: "Novo bot", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 62d4a9d1..7acb2950 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -44,7 +44,7 @@ export const chat: Messages["chat"] = { inbound: { task: "Tarefa", result: "Resultado", - archivedBot: "Um bot arquivado", + goneBot: "Um bot que não está mais na equipe", }, run: { working: "Trabalhando", diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 526e27ed..7cb73c13 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -47,6 +47,7 @@ export const crews: Messages["crews"] = { `Os bots vão trabalhar em ${path}. Cada um reinicia quando terminar o que está fazendo. O que já foi feito fica onde está.`, move: "Mudar", archive: "Arquivar equipe", + delete: "Excluir equipe", tabs: { label: "Visões da equipe", bots: "Bots", @@ -60,12 +61,20 @@ export const crews: Messages["crews"] = { bots === 0 ? "A equipe sai do app." : `A equipe sai do app. ${bots === 1 ? "O bot dela para" : `Os ${bots} bots dela param`}, e as mensagens que ainda esperam por ${bots === 1 ? "ele" : "eles"} não são entregues.`, + deleteTitle: (crew: string) => `Excluir ${crew}?`, + deleteBody: (crew: string, bots: number) => + bots === 0 + ? `${crew} sai do Botloft de vez. Não dá para desfazer.` + : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} param agora e saem do Botloft de vez, com as conversas, as rotinas e as tarefas. Não dá para desfazer.`, + deleteKept: + "As pastas continuam no seu computador, com tudo o que está nelas: a pasta de cada bot e a pasta de trabalho da equipe:", failed: { pause: "Não foi possível pausar a equipe", resume: "Não foi possível retomar a equipe", changeFolder: "Não foi possível mudar a pasta", openFolder: "Não foi possível abrir a pasta", archive: "Não foi possível arquivar a equipe", + delete: "Não foi possível excluir a equipe", }, }, bots: { diff --git a/app/src/i18n/pt-BR/messages.ts b/app/src/i18n/pt-BR/messages.ts index b48332d5..7b73e49d 100644 --- a/app/src/i18n/pt-BR/messages.ts +++ b/app/src/i18n/pt-BR/messages.ts @@ -32,7 +32,7 @@ export const messages: Messages["messages"] = { row: { you: "Você", system: "Botloft", - archivedBot: "um bot arquivado", + goneBot: "um bot que não está mais na equipe", to: "para", task: "Tarefa", result: "Resultado", diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 914fa21b..6057818a 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -1,7 +1,9 @@ // The fake daemon's `bots.*` methods, following the daemon's rules for -// handles, colors, archiving and the notifications each change sends. +// handles, colors, archiving, deleting and the notifications each change +// sends. import type { FakeBotloft, Handlers } from "./fake"; +import { purgeBot } from "./fakeDelete"; import { checkName } from "./fakeRules"; import { AVATAR_PALETTE, type Bot } from "./protocol.gen"; @@ -89,5 +91,18 @@ export function botHandlers(fake: FakeBotloft): Pick { } return bot; }, + "bots.delete": ({ botId }) => { + const bot = fake.bot(botId, false); + purgeBot(fake, bot); + // A deleted chief leaves its crew without one. + const crew = fake.crews.get(bot.crewId); + if (crew?.leadBotId === botId) { + crew.leadBotId = null; + fake.changedCrew(crew); + } + const deleted = { botId, crewId: bot.crewId }; + fake.emit({ name: "bot.deleted", params: deleted }); + return deleted; + }, }; } diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts index 72706174..549817cb 100644 --- a/app/src/lib/fakeCrews.ts +++ b/app/src/lib/fakeCrews.ts @@ -1,8 +1,9 @@ // The fake daemon's `crews.*` methods: names, slugs, the work folder, -// pausing and archiving, with the notifications each change sends. +// pausing, archiving and deleting, with the notifications each change sends. import type { FakeBotloft, Handlers } from "./fake"; import { botHandlers } from "./fakeBots"; +import { purgeBot } from "./fakeDelete"; import { checkName, invalid, slugify } from "./fakeRules"; import type { Crew } from "./protocol.gen"; @@ -81,5 +82,16 @@ export function crewHandlers(fake: FakeBotloft): Pick { } return crew; }, + "crews.delete": ({ crewId }) => { + fake.crew(crewId, false); + for (const bot of [...fake.bots.values()]) { + if (bot.crewId === crewId) { + purgeBot(fake, bot); + } + } + fake.crews.delete(crewId); + fake.emit({ name: "crew.deleted", params: { crewId } }); + return { crewId }; + }, }; } diff --git a/app/src/lib/fakeDelete.ts b/app/src/lib/fakeDelete.ts new file mode 100644 index 00000000..8be4c035 --- /dev/null +++ b/app/src/lib/fakeDelete.ts @@ -0,0 +1,49 @@ +// What the fake daemon forgets when a bot is deleted for good (spec 7.6): +// its chat, its routines, its tasks and what it received. What it sent to +// other bots stays, without a sender. + +import type { FakeBotloft } from "./fake"; +import type { Bot } from "./protocol.gen"; + +/** Drops every item that fails `keep`, in place. */ +function retain(list: T[], keep: (item: T) => boolean): void { + list.splice(0, list.length, ...list.filter(keep)); +} + +/** Removes the bot and everything that was its own. Sends no notification. */ +export function purgeBot(fake: FakeBotloft, bot: Bot): void { + const { chat, conversation, routines } = fake; + fake.bots.delete(bot.id); + retain(chat.items, (item) => item.botId !== bot.id); + for (const [id, approval] of chat.approvals) { + if (approval.botId === bot.id) { + chat.approvals.delete(id); + } + } + for (const [id, routine] of routines.routines) { + if (routine.botId === bot.id) { + routines.routines.delete(id); + } + } + for (const [id, delivery] of conversation.deliveries) { + if (delivery.botId === bot.id) { + conversation.deliveries.delete(id); + } + } + const gone = new Set(); + for (const [id, task] of conversation.tasks) { + if (task.requesterBotId === bot.id || task.assigneeBotId === bot.id) { + conversation.tasks.delete(id); + gone.add(id); + } + } + retain(conversation.messages, (message) => message.toBotId !== bot.id); + for (const message of conversation.messages) { + if (message.fromBotId === bot.id) { + message.fromBotId = null; + } + if (message.taskId !== null && gone.has(message.taskId)) { + message.taskId = null; + } + } +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1aa00419..5b35c1d4 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -360,6 +360,18 @@ fresh?: boolean, }; */ export type BotStateChanged = { botId: BotId, state: BotState, generation: number | null, }; +/** + * A bot that was deleted for good (spec 7.6): the result of `bots.delete` + * and the params of the `bot.deleted` notification. + */ +export type BotDeleted = { botId: BotId, crewId: CrewId, }; + +/** + * A crew that was deleted with every bot in it (spec 7.6): the result of + * `crews.delete` and the params of the `crew.deleted` notification. + */ +export type CrewDeleted = { crewId: CrewId, }; + /** * Who wrote a message. */ @@ -992,6 +1004,7 @@ export interface RpcMethods { "crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew }; "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; + "crews.delete": { params: CrewIdParams; result: CrewDeleted }; "bots.list": { params: BotsListParams; result: Array }; "bots.create": { params: BotsCreateParams; result: Bot }; "bots.update": { params: BotsUpdateParams; result: Bot }; @@ -999,6 +1012,7 @@ export interface RpcMethods { "bots.setPermissionMode": { params: BotsSetPermissionModeParams; result: Bot }; "bots.setModel": { params: BotsSetModelParams; result: Bot }; "bots.archive": { params: BotIdParams; result: Bot }; + "bots.delete": { params: BotIdParams; result: BotDeleted }; "bots.restart": { params: BotsRestartParams; result: Bot }; "chat.history": { params: ChatHistoryParams; result: Array }; "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; @@ -1030,6 +1044,8 @@ export interface RpcMethods { export interface RpcNotifications { "crew.changed": Crew; "bot.changed": Bot; + "crew.deleted": CrewDeleted; + "bot.deleted": BotDeleted; "bot.state": BotStateChanged; "chat.item": ChatItemChanged; "chat.delta": ChatDelta; diff --git a/app/src/store/app.test.ts b/app/src/store/app.test.ts index 5348a7ff..63fbea24 100644 --- a/app/src/store/app.test.ts +++ b/app/src/store/app.test.ts @@ -50,6 +50,68 @@ describe("app store", () => { expect(store.getState().selectedCrewId).toBeNull(); }); + test("a deleted bot leaves with its routines, tasks and deliveries", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + await fake.call("crews.setLead", { crewId: crew.id, botId: scout.id }); + const routine = await fake.call("routines.create", { + botId: scout.id, + name: "Morning", + prompt: "Summarize", + schedule: { kind: "interval", minutes: 60 }, + timezone: "UTC", + }); + const { store } = await synced(fake); + const task = fake.conversation.task(writer.id, scout.id); + const sent = await fake.call("messages.send", { botId: scout.id, body: "Hello" }); + store.getState().selectBot(scout.id); + expect(store.getState().tasks[task.id]).toBeDefined(); + expect(store.getState().deliveries[sent.id]).toBeDefined(); + + expect(await fake.call("bots.delete", { botId: scout.id })).toEqual({ + botId: scout.id, + crewId: crew.id, + }); + const state = store.getState(); + expect(state.bots[scout.id]).toBeUndefined(); + expect(state.bots[writer.id]).toBeDefined(); + expect(state.routines[routine.id]).toBeUndefined(); + expect(state.tasks[task.id]).toBeUndefined(); + expect(state.deliveries[sent.id]).toBeUndefined(); + expect(state.crews[crew.id]?.leadBotId).toBeNull(); + expect(state.selectedBotId).toBeNull(); + expect(state.selectedCrewId).toBe(crew.id); + await expect(fake.call("bots.delete", { botId: scout.id })).rejects.toThrow(); + }); + + test("a deleted crew leaves with its bots, archived or not", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const docs = fake.addCrew("Docs"); + const scout = fake.addBot(ops.id, "Scout"); + const old = fake.addBot(ops.id, "Old"); + const editor = fake.addBot(docs.id, "Editor"); + await fake.call("bots.archive", { botId: old.id }); + const { store } = await synced(fake); + store.getState().selectBot(scout.id); + + await fake.call("crews.delete", { crewId: ops.id }); + const state = store.getState(); + expect(Object.keys(state.crews)).toEqual([docs.id]); + expect(Object.keys(state.bots)).toEqual([editor.id]); + expect(state.selectedCrewId).toBeNull(); + expect(state.selectedBotId).toBeNull(); + expect(fake.bots.has(old.id)).toBe(false); + + // A delete the call returned is applied without waiting for the news. + store.getState().dropBot(editor.id); + store.getState().dropCrew(docs.id); + expect(store.getState().bots).toEqual({}); + expect(store.getState().crews).toEqual({}); + }); + test("reloads everything when the connection comes back", async () => { const fake = new FakeBotloft(); const crew = fake.addCrew("Ops"); diff --git a/app/src/store/app.ts b/app/src/store/app.ts index eb2af0fe..f77fb36d 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -50,6 +50,9 @@ export interface AppState { /** Applies a record a call returned, before its notification arrives. */ putCrew(crew: Crew): void; putBot(bot: Bot): void; + /** Applies a delete a call returned, before its notification arrives. */ + dropCrew(crewId: CrewId): void; + dropBot(botId: BotId): void; putDelivery(delivery: Delivery): void; putRoutine(routine: Routine): void; putBrowser(browser: BrowserState): void; @@ -116,6 +119,8 @@ export function createAppStore(api: BotloftApi): AppStore { }, putCrew: (crew) => set((state) => withCrew(state, crew)), putBot: (bot) => set((state) => withBot(state, bot)), + dropCrew: (crewId) => set((state) => withoutCrew(state, crewId)), + dropBot: (botId) => set((state) => withoutBots(state, [botId])), putRoutine: (routine) => set((state) => withRoutine(state, routine)), putBrowser: (browser) => set((state) => withBrowser(state, browser)), putSettings: (settings) => set({ settings }), @@ -149,12 +154,55 @@ function withBot(state: AppState, bot: Bot): Partial { return { bots, selectedBotId: state.selectedBotId === bot.id ? null : state.selectedBotId }; } +/** Without the entries of `record` that fail `keep`. */ +function kept(record: Record, keep: (value: T) => boolean): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => keep(value))); +} + +/** + * Deleted bots leave with what was theirs (spec 7.6): the daemon sends no + * notification for each routine, task and delivery that went with them. + */ +function withoutBots(state: AppState, botIds: BotId[]): Partial { + const gone = new Set(botIds); + return { + bots: kept(state.bots, (bot) => !gone.has(bot.id)), + routines: kept(state.routines, (routine) => !gone.has(routine.botId)), + browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)), + deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)), + tasks: kept( + state.tasks, + (task) => !gone.has(task.requesterBotId) && !gone.has(task.assigneeBotId), + ), + selectedBotId: + state.selectedBotId !== null && gone.has(state.selectedBotId) ? null : state.selectedBotId, + }; +} + +function withoutCrew(state: AppState, crewId: CrewId): Partial { + const bots = Object.values(state.bots).filter((bot) => bot.crewId === crewId); + const selected = state.selectedCrewId === crewId; + return { + ...withoutBots( + state, + bots.map((bot) => bot.id), + ), + crews: kept(state.crews, (crew) => crew.id !== crewId), + selectedCrewId: selected ? null : state.selectedCrewId, + selectedBotId: selected ? null : state.selectedBotId, + }; +} + export function applyEvent(state: AppState, event: ServerEvent): Partial | null { switch (event.name) { case "crew.changed": return withCrew(state, event.params); + case "crew.deleted": + return withoutCrew(state, event.params.crewId); case "bot.changed": return withBot(state, event.params); + case "bot.deleted": + return withoutBots(state, [event.params.botId]); case "bot.state": { const bot = state.bots[event.params.botId]; if (!bot) { diff --git a/app/src/ui/ContextMenu.tsx b/app/src/ui/ContextMenu.tsx new file mode 100644 index 00000000..c4f5801f --- /dev/null +++ b/app/src/ui/ContextMenu.tsx @@ -0,0 +1,107 @@ +import { type MouseEvent, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { type MenuItem, MenuItems, menuKeys } from "./Menu"; +import { POPOVER } from "./surface"; +import { useDismiss } from "./useDismiss"; + +/** A place in the window, in CSS pixels. */ +export interface Point { + x: number; + y: number; +} + +/** How close to the window's edge a menu may get. */ +const EDGE = 8; + +/** + * Where a `contextmenu` event asks for its menu: at the pointer, or, from + * the keyboard's menu key, which has none, just inside the element. + */ +export function menuPoint(event: MouseEvent): Point { + if (event.clientX !== 0 || event.clientY !== 0) { + return { x: event.clientX, y: event.clientY }; + } + const box = event.currentTarget.getBoundingClientRect(); + return { x: box.left + 24, y: box.top + box.height / 2 }; +} + +/** + * Window pixels, which the pointer is in, per pixel of the page. 1 in the + * app, whose webview does the zoom; the preview zooms the page with CSS. + */ +function pageScale(): number { + const page = document.documentElement; + return (page.offsetWidth && page.getBoundingClientRect().width / page.offsetWidth) || 1; +} + +/** + * The menu of a right-click: the rows of `Menu`, opened at `at` and kept + * inside the window. Closes on a pick, Escape, a click elsewhere, or when + * what is under it moves. + */ +export function ContextMenu({ + label, + items, + at, + onClose, +}: { + label: string; + items: MenuItem[]; + at: Point; + onClose(): void; +}) { + const root = useRef(null); + // Measured and put in place before it is first painted. + const [place, setPlace] = useState(at); + useDismiss(true, root, onClose); + + useLayoutEffect(() => { + const menu = root.current; + if (!menu) { + return; + } + // Its own size, not its box: it is still growing into place. + const scale = pageScale(); + const width = menu.offsetWidth * scale; + const height = menu.offsetHeight * scale; + setPlace({ + x: Math.max(EDGE, Math.min(at.x, window.innerWidth - width - EDGE)) / scale, + y: Math.max(EDGE, Math.min(at.y, window.innerHeight - height - EDGE)) / scale, + }); + // The keyboard follows the menu, and goes back where it was after it. + const before = document.activeElement; + menu.querySelector('[role^="menuitem"]:not(:disabled)')?.focus(); + return () => { + if (menu.contains(document.activeElement) && before instanceof HTMLElement) { + before.focus(); + } + }; + }, [at]); + + useLayoutEffect(() => { + window.addEventListener("resize", onClose); + window.addEventListener("blur", onClose); + window.addEventListener("scroll", onClose, true); + return () => { + window.removeEventListener("resize", onClose); + window.removeEventListener("blur", onClose); + window.removeEventListener("scroll", onClose, true); + }; + }, [onClose]); + + // In the body: no list that scrolls or moves can clip or carry it. + return createPortal( +
    event.preventDefault()} + style={{ left: place.x, top: place.y }} + className={`fixed min-w-60 origin-top-left ${POPOVER}`} + > + +
    , + document.body, + ); +} diff --git a/app/src/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx new file mode 100644 index 00000000..3f07db07 --- /dev/null +++ b/app/src/ui/KeptFolder.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +/** In a confirmation: what stays on disk, and the folder it is in. */ +export function KeptFolder({ path, children }: { path: string; children: ReactNode }) { + return ( +
    +

    {children}

    +

    + {path} +

    +
    + ); +} diff --git a/app/src/ui/Menu.tsx b/app/src/ui/Menu.tsx index 8341378f..5e22fc03 100644 --- a/app/src/ui/Menu.tsx +++ b/app/src/ui/Menu.tsx @@ -1,5 +1,5 @@ import { Check, type LucideIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useRef, useState } from "react"; import { Button } from "./Button"; import { POPOVER, POPOVER_ITEM } from "./surface"; import { useDismiss } from "./useDismiss"; @@ -14,6 +14,59 @@ export interface MenuItem { onSelect(): void; } +/** The arrow keys, Home and End walk the items of the menu they are pressed in. */ +export function menuKeys(event: KeyboardEvent): void { + const items = [ + ...event.currentTarget.querySelectorAll('[role^="menuitem"]:not(:disabled)'), + ]; + const last = items.length - 1; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number; + switch (event.key) { + case "ArrowDown": + next = at >= last ? 0 : at + 1; + break; + case "ArrowUp": + next = at <= 0 ? last : at - 1; + break; + case "Home": + next = 0; + break; + case "End": + next = last; + break; + default: + return; + } + event.preventDefault(); + items[next]?.focus(); +} + +/** The rows of a menu. `onPick` runs first: it closes the menu. */ +export function MenuItems({ items, onPick }: { items: MenuItem[]; onPick(): void }) { + return items.map(({ label, icon: Icon, danger, disabled, checked, onSelect }) => ( + + )); +} + /** A button that opens a short list of actions. */ export function Menu({ label, @@ -46,29 +99,10 @@ export function Menu({ {open && (
    - {items.map(({ label: itemLabel, icon: Icon, danger, disabled, checked, onSelect }) => ( - - ))} + setOpen(false)} />
    )} diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 9e4c5249..09f5971a 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -10,6 +10,9 @@ pub enum Sender<'a> { Bot { handle: &'a str, }, + /// A bot the owner deleted after it sent the message (spec 7.6): there + /// is nobody to reply to. + DeletedBot, /// The daemon, e.g. to say a task expired. Botloft, } @@ -62,6 +65,7 @@ impl Envelope<'_> { // No `@`: a bot may well be called "Owner". Sender::Owner => "the owner".to_owned(), Sender::Bot { handle } => format!("@{handle}"), + Sender::DeletedBot => "a deleted bot".to_owned(), Sender::Botloft => "Botloft".to_owned(), } } @@ -168,6 +172,15 @@ mod tests { ); } + #[test] + fn a_deleted_sender_cannot_be_answered() { + let text = envelope(Sender::DeletedBot, MessageKind::Note, None).render(0); + assert_eq!( + text, + "[botloft] from a deleted bot · crew Site\n\nCheck the build.\nThanks!" + ); + } + #[test] fn results_and_notices_carry_the_task_status() { let done = task(0, TaskStatus::Done); diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 7e5617a0..c7a302a7 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -125,6 +125,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -223,6 +225,13 @@ fn export_bindings() { &crew, ); out.method(method::CREWS_ARCHIVE, &out.name::(), &crew); + let crew_deleted = out.name::(); + let bot_deleted = out.name::(); + out.method( + method::CREWS_DELETE, + &out.name::(), + &crew_deleted, + ); out.method(method::BOTS_LIST, &out.name::(), &bots); out.method(method::BOTS_CREATE, &out.name::(), &bot); out.method(method::BOTS_UPDATE, &out.name::(), &bot); @@ -242,6 +251,11 @@ fn export_bindings() { &bot, ); out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); + out.method( + method::BOTS_DELETE, + &out.name::(), + &bot_deleted, + ); out.method(method::BOTS_RESTART, &out.name::(), &bot); out.method( method::CHAT_HISTORY, @@ -304,6 +318,16 @@ fn export_bindings() { out.text.push_str("export interface RpcNotifications {\n"); let _ = writeln!(out.text, " \"{}\": {crew};", notification::CREW_CHANGED); let _ = writeln!(out.text, " \"{}\": {bot};", notification::BOT_CHANGED); + let _ = writeln!( + out.text, + " \"{}\": {crew_deleted};", + notification::CREW_DELETED + ); + let _ = writeln!( + out.text, + " \"{}\": {bot_deleted};", + notification::BOT_DELETED + ); let state = out.name::(); let item = out.name::(); let delta = out.name::(); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 43054c83..60b971bd 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -89,6 +89,7 @@ pub mod method { pub const CREWS_SET_WORK_FOLDER: &str = "crews.setWorkFolder"; pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; + pub const CREWS_DELETE: &str = "crews.delete"; pub const BOTS_LIST: &str = "bots.list"; pub const BOTS_CREATE: &str = "bots.create"; pub const BOTS_UPDATE: &str = "bots.update"; @@ -96,6 +97,7 @@ pub mod method { pub const BOTS_SET_PERMISSION_MODE: &str = "bots.setPermissionMode"; pub const BOTS_SET_MODEL: &str = "bots.setModel"; pub const BOTS_ARCHIVE: &str = "bots.archive"; + pub const BOTS_DELETE: &str = "bots.delete"; pub const BOTS_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; pub const APPROVALS_ANSWER: &str = "approvals.answer"; @@ -128,7 +130,9 @@ pub mod method { /// Server notification method names. pub mod notification { pub const CREW_CHANGED: &str = "crew.changed"; + pub const CREW_DELETED: &str = "crew.deleted"; pub const BOT_CHANGED: &str = "bot.changed"; + pub const BOT_DELETED: &str = "bot.deleted"; pub const BOT_STATE: &str = "bot.state"; pub const CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 7d890865..169ca45f 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -160,3 +160,22 @@ pub struct BotStateChanged { pub state: BotState, pub generation: Option, } + +/// A bot that was deleted for good (spec 7.6): the result of `bots.delete` +/// and the params of the `bot.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotDeleted { + pub bot_id: BotId, + pub crew_id: CrewId, +} + +/// A crew that was deleted with every bot in it (spec 7.6): the result of +/// `crews.delete` and the params of the `crew.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewDeleted { + pub crew_id: CrewId, +} diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs index 72574322..8a5c1568 100644 --- a/crates/botloft-store/src/approvals.rs +++ b/crates/botloft-store/src/approvals.rs @@ -95,6 +95,15 @@ impl Store { .optional()?) } + /// The bot's requests that still wait for the owner. + pub fn pending_approvals(&self, bot: &BotId) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT id FROM approvals WHERE bot_id = ?1 AND status = 'pending'")?; + let rows = stmt.query_map([bot.as_str()], |row| parse_column(row, 0))?; + Ok(rows.collect::>()?) + } + /// Expires every pending approval, of one bot or of all: their process /// is gone, so nobody waits for the answer. pub fn expire_approvals(&self, bot: Option<&BotId>, now: i64) -> Result> { @@ -204,6 +213,10 @@ mod tests { let fx = Fixture::new(); let first = pending(&fx, 0); let other = pending(&fx, 1); + assert_eq!( + fx.store.pending_approvals(&fx.bots[0].id).expect("pending"), + vec![first.approval.id.clone()] + ); let expired = fx .store .expire_approvals(Some(&fx.bots[0].id), 40) diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs new file mode 100644 index 00000000..1d586340 --- /dev/null +++ b/crates/botloft-store/src/delete.rs @@ -0,0 +1,77 @@ +//! Deleting a bot or a crew for good (spec 7.6 and 12). Archiving keeps the +//! rows; this removes them with everything that points at them, in one +//! transaction. + +use botloft_core::ids::{BotId, CrewId}; +use rusqlite::Connection; + +use crate::{Result, Store}; + +/// Every statement takes the bot's id. The order follows the foreign keys: +/// what points at a row goes, or lets go of it, before the row. +const DELETE_BOT: &[&str] = &[ + "DELETE FROM approvals WHERE bot_id = ?1", + "DELETE FROM chat_items WHERE bot_id = ?1", + "DELETE FROM browser_sites WHERE bot_id = ?1", + "DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + // What the bot received goes with it. + "UPDATE routine_runs SET message_id = NULL \ + WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM attachments WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM deliveries WHERE bot_id = ?1 \ + OR message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM messages WHERE to_bot_id = ?1", + "UPDATE messages SET routine_id = NULL \ + WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + "DELETE FROM routines WHERE bot_id = ?1", + // What it sent stays with who received it, without a sender. + "UPDATE messages SET from_bot_id = NULL WHERE from_bot_id = ?1", + "UPDATE messages SET task_id = NULL WHERE task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "UPDATE tasks SET origin_task_id = NULL WHERE origin_task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "DELETE FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1", + "UPDATE crews SET lead_bot_id = NULL WHERE lead_bot_id = ?1", +]; + +fn delete_bot_in(conn: &Connection, bot: &str) -> Result { + for statement in DELETE_BOT { + conn.execute(statement, [bot])?; + } + Ok(conn.execute("DELETE FROM bots WHERE id = ?1", [bot])? > 0) +} + +impl Store { + /// Deletes the bot with its chat, its approvals, the messages it + /// received, its routines, its browser sites and the tasks it asked for + /// or was given. Messages it sent to other bots stay, without a sender. + /// `false`, with nothing changed, if there is no such bot. + pub fn delete_bot(&self, id: &BotId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let deleted = delete_bot_in(&tx, id.as_str())?; + tx.commit()?; + Ok(deleted) + } + + /// Deletes the crew and every bot in it, archived ones too, as + /// [`Store::delete_bot`] does. `false` if there is no such crew. + pub fn delete_crew(&self, id: &CrewId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let bots: Vec = { + let mut stmt = tx.prepare("SELECT id FROM bots WHERE crew_id = ?1")?; + let rows = stmt.query_map([id.as_str()], |row| row.get(0))?; + rows.collect::>()? + }; + for bot in &bots { + delete_bot_in(&tx, bot)?; + } + // Every message and task of a crew is to or for one of its bots, so + // nothing of the crew is left to hold it. + let deleted = tx.execute("DELETE FROM crews WHERE id = ?1", [id.as_str()])? > 0; + tx.commit()?; + Ok(deleted) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs new file mode 100644 index 00000000..8a4e53c4 --- /dev/null +++ b/crates/botloft-store/src/delete/tests.rs @@ -0,0 +1,304 @@ +//! Tests for deleting bots and crews. + +use botloft_core::ids::{ApprovalId, AttachmentId, ChatItemId, RoutineId, RoutineRunId, TaskId}; +use botloft_core::protocol::{ + Approval, ApprovalItem, ApprovalStatus, Attachment, ChatBody, ChatItem, Message, MessageKind, + Missed, Overlap, Routine, RoutineRun, RunStatus, Schedule, SenderKind, Task, TaskStatus, +}; + +use super::*; +use crate::tests::{Fixture, message_to}; +use crate::{ApprovalRecord, MessageFilter, TaskFilter}; + +/// A message from bot `from` to bot `to` of the fixture. +fn from_bot(fx: &Fixture, from: usize, to: usize, body: &str) -> Message { + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, body); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + fx.store + .insert_message(&message, &delivery, None) + .expect("message"); + message +} + +/// Bot `from` asks bot `to` for a task. +fn ask(fx: &Fixture, from: usize, to: usize, origin: Option<&Task>) -> Task { + let task = Task { + id: TaskId::generate(), + crew_id: fx.crew.id.clone(), + requester_bot_id: fx.bots[from].id.clone(), + assignee_bot_id: fx.bots[to].id.clone(), + status: TaskStatus::Open, + deadline_at: 1_000, + hops: 1, + origin_task_id: origin.map(|task| task.id.clone()), + result: None, + created_at: 0, + updated_at: 0, + }; + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, "do it"); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + message.kind = MessageKind::Task; + message.task_id = Some(task.id.clone()); + fx.store + .insert_message(&message, &delivery, Some(&task)) + .expect("task"); + task +} + +/// Everything a bot can own, for bot 0 (scout): a note from the owner with +/// a file, an approval, a browser site and a routine that ran once. +struct Owned { + attachment: AttachmentId, + approval: ApprovalId, + routine: RoutineId, +} + +fn fill(fx: &Fixture) -> Owned { + let scout = &fx.bots[0].id; + let (mut note, delivery) = message_to(&fx.crew.id, scout, "see attached"); + let attachment = AttachmentId::generate(); + note.attachments = vec![Attachment { + id: attachment.clone(), + name: "plan.pdf".into(), + media_type: "application/pdf".into(), + size: 2048, + path: "attachments/2026-09-30/plan.pdf".into(), + }]; + fx.store + .insert_message(¬e, &delivery, None) + .expect("note"); + + let approval = Approval { + id: ApprovalId::generate(), + bot_id: scout.clone(), + tool_name: "Bash".into(), + summary: "ls".into(), + input: r#"{"command":"ls"}"#.into(), + status: ApprovalStatus::Pending, + note: None, + created_at: 10, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: scout.clone(), + body: ChatBody::Approval(ApprovalItem { + approval_id: approval.id.clone(), + tool_name: approval.tool_name.clone(), + summary: approval.summary.clone(), + input: approval.input.clone(), + status: ApprovalStatus::Pending, + note: None, + }), + created_at: 10, + updated_at: 10, + }; + fx.store.insert_chat_item(&item).expect("item"); + let record = ApprovalRecord { + approval, + chat_item_id: item.id, + tool_use_id: "toolu_1".into(), + }; + fx.store.insert_approval(&record).expect("approval"); + fx.store + .allow_browser_site(scout, "example.com", 10) + .expect("site"); + + let routine = Routine { + id: RoutineId::generate(), + bot_id: scout.clone(), + name: "Morning".into(), + prompt: "Summarize".into(), + schedule: Schedule::Interval { minutes: 60 }, + timezone: "UTC".into(), + overlap: Overlap::Skip, + missed: Missed::RunOnce, + enabled: true, + next_run_at: Some(100), + last_run: None, + created_at: 0, + updated_at: 0, + archived_at: None, + }; + fx.store.insert_routine(&routine).expect("routine"); + let (mut fired, delivery) = message_to(&fx.crew.id, scout, "Summarize"); + fired.from_kind = SenderKind::System; + fired.routine_id = Some(routine.id.clone()); + let run = RoutineRun { + id: RoutineRunId::generate(), + routine_id: routine.id.clone(), + scheduled_for: 100, + status: RunStatus::Queued, + reason: None, + skipped_count: 0, + message_id: Some(fired.id.clone()), + created_at: 100, + finished_at: None, + }; + fx.store.fire_run(&run, &fired, &delivery).expect("run"); + Owned { + attachment, + approval: record.approval.id, + routine: routine.id, + } +} + +fn count(store: &Store, table: &str) -> i64 { + store + .conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count") +} + +/// Rows that point at something that is gone. +fn dangling(store: &Store) -> usize { + let mut stmt = store + .conn + .prepare("PRAGMA foreign_key_check") + .expect("check"); + stmt.query_map([], |_| Ok(())).expect("rows").count() +} + +#[test] +fn a_deleted_bot_takes_what_was_its_own() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let owned = fill(&fx); + let mut crew = fx.crew.clone(); + crew.lead_bot_id = Some(scout.clone()); + fx.store.update_crew(&crew).expect("chief"); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + assert_eq!(fx.store.bot(&scout).expect("bot"), None); + assert!(fx.store.bot(&writer).expect("bot").is_some()); + assert!( + fx.store + .chat_history(&scout, None, 50) + .expect("chat") + .is_empty() + ); + assert!( + fx.store + .deliveries(None, Some(&scout)) + .expect("deliveries") + .is_empty() + ); + assert_eq!(fx.store.attachment(&owned.attachment).expect("file"), None); + assert_eq!(fx.store.approval(&owned.approval).expect("approval"), None); + assert_eq!(fx.store.routine(&owned.routine).expect("routine"), None); + assert!( + fx.store + .runs(&owned.routine, None, 10) + .expect("runs") + .is_empty() + ); + assert!(fx.store.browser_sites(&scout).expect("sites").is_empty()); + let crew = fx.store.crew(&fx.crew.id).expect("crew").expect("kept"); + assert_eq!(crew.lead_bot_id, None); + assert_eq!(dangling(&fx.store), 0); + + assert!(!fx.store.delete_bot(&scout).expect("again"), "already gone"); +} + +#[test] +fn what_a_deleted_bot_sent_stays_without_a_sender() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let note = from_bot(&fx, 0, 1, "the draft is in shared/"); + let reply = from_bot(&fx, 1, 0, "thanks"); + let asked = ask(&fx, 0, 1, None); + let given = ask(&fx, 1, 0, None); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + let kept = fx.store.message(¬e.id).expect("read").expect("kept"); + assert_eq!(kept.from_kind, SenderKind::Bot); + assert_eq!(kept.from_bot_id, None); + assert_eq!(fx.store.message(&reply.id).expect("read"), None); + // The writer's chat still shows what it received. + let chat = fx.store.chat_history(&writer, None, 50).expect("chat"); + assert_eq!(chat.len(), 2); + + // Tasks to and from the deleted bot go; the request the writer got + // stays as a message, without its task. + assert_eq!(fx.store.task(&asked.id).expect("task"), None); + assert_eq!(fx.store.task(&given.id).expect("task"), None); + let request = fx + .store + .messages(MessageFilter { + bot: Some(&writer), + limit: 10, + ..MessageFilter::default() + }) + .expect("messages"); + assert_eq!(request.len(), 2); + assert!(request.iter().all(|message| message.task_id.is_none())); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_chain_of_tasks_loses_only_the_deleted_link() { + let mut fx = Fixture::new(); + let mut editor = fx.bots[1].clone(); + editor.id = botloft_core::ids::BotId::generate(); + editor.handle = "editor".into(); + editor.slug = "editor".into(); + fx.store.insert_bot(&editor).expect("third bot"); + fx.bots.push(editor); + + // Scout asks the writer, who asks the editor. + let first = ask(&fx, 0, 1, None); + let second = ask(&fx, 1, 2, Some(&first)); + + assert!(fx.store.delete_bot(&fx.bots[0].id).expect("delete")); + + let left = fx.store.tasks(TaskFilter::default()).expect("tasks"); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, second.id); + assert_eq!(left[0].origin_task_id, None); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() { + let fx = Fixture::new(); + fill(&fx); + from_bot(&fx, 0, 1, "hello"); + ask(&fx, 1, 0, None); + // An archived bot of the crew goes too. + let mut archived = fx.bots[1].clone(); + archived.archived_at = Some(5); + fx.store.update_bot(&archived).expect("archive"); + + let other = Fixture::new().crew; + let other = botloft_core::protocol::Crew { + slug: "other".into(), + ..other + }; + fx.store.insert_crew(&other).expect("other crew"); + + assert!(fx.store.delete_crew(&fx.crew.id).expect("delete")); + + assert_eq!(fx.store.crews(true).expect("crews"), vec![other]); + for table in [ + "bots", + "messages", + "deliveries", + "attachments", + "chat_items", + "approvals", + "tasks", + "routines", + "routine_runs", + "browser_sites", + ] { + assert_eq!(count(&fx.store, table), 0, "{table}"); + } + assert_eq!(dangling(&fx.store), 0); + assert!(!fx.store.delete_crew(&fx.crew.id).expect("again")); +} diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index ff856452..65238c4b 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -9,6 +9,7 @@ mod bots; mod browser_sites; mod chat; mod crews; +mod delete; mod deliveries; mod messages; mod migrate; diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index bc0e4411..bec38887 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -57,6 +57,14 @@ impl Approvals { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// Lets go of requests nobody will answer: their bot was deleted. + pub(crate) fn forget(&self, requests: &[ApprovalId]) { + let mut waiting = self.lock(); + for request in requests { + waiting.remove(request); + } + } } /// Puts a request in the bot's chat and waits for the owner. `None` if it diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index 6da1efeb..2283adb7 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -39,14 +39,14 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend let text = match message.from_kind { // The owner is the session's user: their words go as written. SenderKind::Owner => with_attachment_list(&message.body, &message.attachments), - SenderKind::Bot => envelope( - message, - context, - Sender::Bot { - handle: context.sender_handle.unwrap_or("unknown"), - }, - now, - ), + SenderKind::Bot => { + let from = match context.sender_handle { + Some(handle) => Sender::Bot { handle }, + // Deleted while the message waited (spec 7.6). + None => Sender::DeletedBot, + }; + envelope(message, context, from, now) + } SenderKind::System => match context.routine { Some((name, scheduled, timezone)) => RoutineEnvelope { name, diff --git a/crates/botloftd/src/routines/mod.rs b/crates/botloftd/src/routines/mod.rs index bce735bc..640a20b2 100644 --- a/crates/botloftd/src/routines/mod.rs +++ b/crates/botloftd/src/routines/mod.rs @@ -43,6 +43,11 @@ impl Routines { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// The bot was deleted, and its runs with it. + pub(crate) fn forget(&self, bot: &BotId) { + self.turns().remove(bot); + } } /// Runs the scheduler until the daemon stops. The first pass also ends the diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 4f62a504..9d37c7e0 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,8 +8,8 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, - modes, routines, screens, settings, tasks, + self, ApiResult, attachments, bots, chat, crews, delete, deliveries, files, lead, messages, + models, modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -28,6 +28,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(crews::set_work_folder(daemon, parse(params)?)), method::CREWS_SET_LEAD => reply(lead::set_lead(daemon, parse(params)?)), method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), + method::CREWS_DELETE => reply(delete::crew(daemon, parse(params)?)), method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)), method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)), method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)), @@ -37,6 +38,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(models::set_model(daemon, parse(params)?)), method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)), + method::BOTS_DELETE => reply(delete::bot(daemon, parse(params)?)), method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index b5105ef1..8b935c2b 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -199,7 +199,9 @@ fn handle( fn to_notification(event: &Event) -> String { let (name, params) = match event { Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), + Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), + Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)), Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), diff --git a/crates/botloftd/src/screens/mod.rs b/crates/botloftd/src/screens/mod.rs index cc9804e0..0e49d406 100644 --- a/crates/botloftd/src/screens/mod.rs +++ b/crates/botloftd/src/screens/mod.rs @@ -79,6 +79,12 @@ impl Screens { .clone() } + /// The bot was deleted: its key stops working and its drafts go. + pub(crate) fn forget(&self, bot: &BotId) { + self.lock_keys().remove(bot); + self.drafts().remove(bot); + } + /// The bot a key belongs to. pub fn bot_of(&self, key: &str) -> Option { if key.is_empty() { diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs new file mode 100644 index 00000000..1efdad1e --- /dev/null +++ b/crates/botloftd/src/service/delete.rs @@ -0,0 +1,149 @@ +//! `bots.delete` and `crews.delete` (spec 7.6): removing a bot or a crew +//! for good, active or archived. Archiving keeps everything in the +//! database; deleting keeps only the folders on disk (spec 5). + +use botloft_core::ids::{BotId, MessageId}; +use botloft_core::protocol::{ + BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind, +}; +use botloft_store::{BotRecord, Store, TaskFilter}; +use tracing::{info, warn}; + +use super::messages::post; +use super::tasks::UNFINISHED; +use super::{ApiResult, bots, crews, lead}; +use crate::state::{Daemon, Event}; + +/// Deletes the bot: its process stops at once and its conversation, +/// routines and tasks leave the database. Its folder stays. +pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { + let store = daemon.store(); + let record = bots::find(&store, ¶ms.bot_id)?; + let crew = crews::find(&store, &record.crew_id)?; + let notices = task_notices(&store, &crew, &record)?; + let waiting = store.pending_approvals(&record.id)?; + store.delete_bot(&record.id)?; + stop(daemon, &record.id); + + if lead::is_lead(&crew, &record.id) { + // The crew lost its chief with the bot. + crews::changed(daemon, crews::find(&store, &crew.id)?); + } + let deleted = BotDeleted { + bot_id: record.id, + crew_id: crew.id.clone(), + }; + daemon.emit(Event::BotDeleted(deleted.clone())); + for (to, body) in notices { + tell(daemon, &store, &crew, to, body); + } + drop(store); + daemon.approvals.forget(&waiting); + release(daemon, &deleted.bot_id); + info!(bot = %deleted.bot_id, "bot deleted"); + Ok(deleted) +} + +/// Deletes the crew with every bot in it, archived ones too. Its folders +/// stay. +pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { + let store = daemon.store(); + let crew = crews::find(&store, ¶ms.crew_id)?; + let bots = store.bots(Some(&crew.id), true)?; + let mut waiting = Vec::new(); + for bot in &bots { + waiting.extend(store.pending_approvals(&bot.id)?); + } + store.delete_crew(&crew.id)?; + for bot in &bots { + stop(daemon, &bot.id); + } + let deleted = CrewDeleted { crew_id: crew.id }; + daemon.emit(Event::CrewDeleted(deleted.clone())); + drop(store); + daemon.approvals.forget(&waiting); + for bot in &bots { + release(daemon, &bot.id); + } + info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted"); + Ok(deleted) +} + +/// Kills the bot's process and closes its browser, profile included. +fn stop(daemon: &Daemon, bot: &BotId) { + daemon.supervisor.forget(bot); + daemon.browsers.forget(bot); +} + +/// Lets go of what the daemon kept in memory for the bot. Called without +/// the store: the routines take their own lock before it. +fn release(daemon: &Daemon, bot: &BotId) { + daemon.routines.forget(bot); + daemon.screens.forget(bot); +} + +/// What the other bots should hear about the unfinished tasks a deleted +/// bot leaves (spec 7.6): who to tell, and what. Only bots that still run +/// are told. +fn task_notices(store: &Store, crew: &Crew, bot: &BotRecord) -> ApiResult> { + if crew.archived_at.is_some() { + return Ok(Vec::new()); + } + let handle = &bot.handle; + let owed = store.tasks(TaskFilter { + assignee: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let asked = store.tasks(TaskFilter { + requester: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let owed = owed.into_iter().rev().map(|task| { + let body = format!( + "Task {} for @{handle} will not be done: the owner deleted @{handle}. Get it done \ + another way, or tell the owner what is missing.", + task.id + ); + (task.requester_bot_id, body) + }); + let asked = asked.into_iter().rev().map(|task| { + let body = format!( + "Task {} from @{handle} no longer needs a result: the owner deleted @{handle}. \ + Stop working on it.", + task.id + ); + (task.assignee_bot_id, body) + }); + let mut notices = Vec::new(); + for (to, body) in owed.chain(asked) { + if store + .bot(&to)? + .is_some_and(|other| other.archived_at.is_none()) + { + notices.push((to, body)); + } + } + Ok(notices) +} + +/// A notice from Botloft to a bot of the crew. +fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { + let notice = Message { + id: MessageId::generate(), + crew_id: crew.id.clone(), + from_kind: SenderKind::System, + from_bot_id: None, + to_bot_id: to, + kind: MessageKind::System, + body, + task_id: None, + routine_id: None, + attachments: Vec::new(), + created_at: daemon.clock.now_ms(), + }; + if let Err(err) = post(daemon, store, notice, None) { + warn!(crew = %crew.id, "could not tell a bot about a deleted bot's task: {err}"); + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 7aba88e0..a3fc2343 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -5,6 +5,7 @@ pub mod attachments; pub mod bots; pub mod chat; pub mod crews; +pub mod delete; pub mod deliveries; pub mod files; pub mod lead; diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index 27355b3f..c1c18006 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -22,7 +22,7 @@ use crate::state::Daemon; /// Longest deadline a bot may set: a week. pub const MAX_DEADLINE_MINUTES: u32 = 7 * 24 * 60; /// Tasks that still expect a result. -const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; +pub(crate) const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; #[derive(Debug, Clone)] pub struct TaskSettings { diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index f947f00a..73742cfc 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, Bot, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, - Crew, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, + ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -30,7 +30,9 @@ use crate::workspace::WorkspaceEnv; #[derive(Debug, Clone, PartialEq)] pub enum Event { CrewChanged(Crew), + CrewDeleted(CrewDeleted), BotChanged(Bot), + BotDeleted(BotDeleted), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), diff --git a/crates/botloftd/src/supervisor/forget.rs b/crates/botloftd/src/supervisor/forget.rs new file mode 100644 index 00000000..0b1c84e5 --- /dev/null +++ b/crates/botloftd/src/supervisor/forget.rs @@ -0,0 +1,36 @@ +//! A deleted bot leaves the supervisor (spec 7.6). + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tracing::warn; + +use super::{Inner, Supervisor}; + +impl Supervisor { + /// The bot was deleted: its process is killed at once, without waiting + /// for its turn, and nothing of it is kept. Whatever the process still + /// prints is ignored, and its exit finds no slot. + pub fn forget(&self, bot: &BotId) { + let mut inner = self.lock(); + let Inner { + slots, + tokens, + sessions, + gone, + .. + } = &mut *inner; + // A pass that read the bot just before must not start it again. + gone.insert(bot.clone()); + sessions.remove(bot); + let Some(slot) = slots.remove(bot) else { + return; + }; + self.count_busy(slot.state, BotState::Offline); + if let Some(running) = slot.running { + tokens.remove(&running.token_hash); + if let Err(err) = running.control.kill() { + warn!(bot = %bot, "could not stop a deleted bot: {err}"); + } + } + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 0a4f83c0..d7e33098 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -5,6 +5,7 @@ //! Lock order: the store lock may be held while taking the supervisor lock //! (service calls read states), never the other way around. +mod forget; mod reconcile; mod relaunch; mod settings; @@ -13,7 +14,7 @@ mod slot; mod spawn; mod turns; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::Duration; @@ -64,6 +65,8 @@ struct Inner { /// The conversation each bot resumes, as far as this run knows; the /// database has it too (spec 7.3). sessions: HashMap, + /// Bots that were deleted while this daemon ran (spec 7.6). + gone: HashSet, claude: ClaudeStatus, sign_in: sign_in::SignIn, } diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 947a459d..1e2ce5ee 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -70,6 +70,10 @@ impl Supervisor { bot: &BotRecord, now: Instant, ) { + // Deleted after this pass read the database. + if inner.gone.contains(&bot.id) { + return; + } let archived = bot.archived_at.is_some() || crew.archived_at.is_some(); if archived && !inner.slots.contains_key(&bot.id) { return; diff --git a/crates/botloftd/tests/delete.rs b/crates/botloftd/tests/delete.rs new file mode 100644 index 00000000..22edd63f --- /dev/null +++ b/crates/botloftd/tests/delete.rs @@ -0,0 +1,259 @@ +//! Deleting bots and crews for good (spec 7.6): what stops, what leaves +//! the database and what stays on disk. What the other bots hear is in +//! `delete_messages.rs`. + +mod common; + +use std::time::Duration; + +use botloft_core::ids::BotId; +use common::bots::{ready_bot, two_bots}; +use common::mcp::Mcp; +use common::stream; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +fn id_of(bot: &Value) -> BotId { + bot["id"].as_str().expect("id").parse().expect("bot id") +} + +#[tokio::test] +async fn a_deleted_bot_stops_and_leaves_only_its_folder() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let mut watcher = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (scout, process, _) = ready_bot(&t, &mut app, &crew, "Scout").await; + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "Hello" }), + ) + .await + .expect("send"); + process.wait_lines(1).await; + let workspace = t.paths.bot_workspace("ops", "scout"); + std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory"); + + let deleted = app + .call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete"); + assert_eq!( + deleted, + json!({ "botId": scout["id"], "crewId": crew["id"] }) + ); + assert_eq!(watcher.notification("bot.deleted").await, deleted); + assert!(process.killed(), "the process does not outlive the bot"); + assert_eq!(t.daemon.supervisor.status(&id_of(&scout)), None); + + assert_eq!( + app.call("bots.list", json!({})).await.expect("bots"), + json!([]) + ); + for (method, params) in [ + ("chat.history", json!({ "botId": scout["id"] })), + ("messages.list", json!({ "botId": scout["id"] })), + ("bots.delete", json!({ "botId": scout["id"] })), + ( + "messages.send", + json!({ "botId": scout["id"], "body": "Still there?" }), + ), + ] { + let gone = app.call(method, params).await.expect_err(method); + assert_eq!(gone.code, NOT_FOUND, "{method}"); + } + let timeline = app + .call("messages.list", json!({ "crewId": crew["id"] })) + .await + .expect("timeline"); + assert_eq!(timeline, json!([])); + + // The folder stays as it was, and nothing starts the bot again. + assert_eq!( + std::fs::read_to_string(workspace.join("CLAUDE.md")).expect("memory"), + "what the bot learned" + ); + t.daemon.supervisor.reconcile().await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(t.runtime.processes().len(), 1); +} + +#[tokio::test] +async fn deleting_the_chief_leaves_the_crew_without_one() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let lead = json!({ "name": "Chief", "role": "Leads", "instructions": "Ship the site." }); + let crew = app + .call("crews.create", json!({ "name": "Ops", "lead": lead })) + .await + .expect("crew"); + let mut watcher = t.session().await; + app.call("bots.delete", json!({ "botId": crew["leadBotId"] })) + .await + .expect("delete"); + let changed = watcher.notification("crew.changed").await; + assert_eq!(changed["leadBotId"], Value::Null); + let crews = app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews[0]["leadBotId"], Value::Null); +} + +#[tokio::test] +async fn archived_bots_and_crews_can_be_deleted_too() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let scout = app.call("bots.create", bot.clone()).await.expect("bot"); + app.call("bots.archive", json!({ "botId": scout["id"] })) + .await + .expect("archive"); + app.call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete an archived bot"); + + // The folder is still there, so a new bot of the same name gets another. + let again = app.call("bots.create", bot).await.expect("bot"); + assert_eq!(again["handle"], "scout"); + assert_eq!(again["slug"], "scout-2"); + + app.call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("archive"); + app.call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect("delete an archived crew"); + let gone = app + .call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect_err("already gone"); + assert_eq!(gone.code, NOT_FOUND); +} + +#[tokio::test] +async fn a_deleted_crew_takes_its_bots_and_keeps_its_folders() { + let mut c = two_bots().await; + let mut watcher = c.t.session().await; + let other = c + .app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("other crew"); + let (editor, editor_process, _) = ready_bot(&c.t, &mut c.app, &other, "Editor").await; + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "Please help.", "kind": "task" }), + ) + .await + .expect("task"); + c.writer_process.wait_lines(1).await; + let lead = bot_named(&mut c.app, "Lead").await; + let shared = c.t.paths.shared_dir("ops"); + std::fs::write(shared.join("report.md"), "the work").expect("work file"); + + let deleted = c + .app + .call("crews.delete", json!({ "crewId": c.crew["id"] })) + .await + .expect("delete"); + assert_eq!(deleted, json!({ "crewId": c.crew["id"] })); + assert_eq!(watcher.notification("crew.deleted").await, deleted); + assert!(c.lead_process.killed() && c.writer_process.killed()); + assert!(!editor_process.killed(), "other crews keep running"); + assert_eq!(c.t.daemon.supervisor.status(&id_of(&lead)), None); + + let crews = c.app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews, json!([other])); + let bots = c.app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(bots.as_array().expect("list").len(), 1); + assert_eq!(bots[0]["id"], editor["id"]); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + // A deleted bot's token opens nothing. + let refused = c.writer.request("ping", json!({})).await; + assert_eq!(refused.status, 401); + + assert_eq!( + std::fs::read_to_string(shared.join("report.md")).expect("work file"), + "the work" + ); + assert!(c.t.paths.bot_workspace("ops", "lead").is_dir()); +} + +#[tokio::test] +async fn a_request_waiting_for_the_owner_goes_with_the_bot() { + let mut c = two_bots().await; + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": "Build it" }), + ) + .await + .expect("send"); + let line = c.lead_process.wait_lines(1).await[0].clone(); + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::tool_use( + "toolu_1", + "Bash", + json!({ "command": "make" }), + )) + .await; + let mut asking = Mcp::new(c.lead.addr, c.lead.token.clone()); + let args = + json!({ "tool_name": "Bash", "input": { "command": "make" }, "tool_use_id": "toolu_1" }); + let decision = tokio::spawn(async move { + asking + .tool("permission_prompt", args) + .await + .expect("answer") + }); + let request = loop { + let item = c.app.notification("chat.item").await; + if item["item"]["body"]["kind"] == "approval" { + break item["item"]["body"]["approvalId"].clone(); + } + }; + + let started = std::time::Instant::now(); + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + let outcome = decision.await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); + assert!( + started.elapsed() < Duration::from_secs(2), + "the request did not wait for its 3 s timeout" + ); + let gone = c + .app + .call( + "approvals.answer", + json!({ "approvalId": request, "allow": true }), + ) + .await + .expect_err("the request went with the bot"); + assert_eq!(gone.code, NOT_FOUND); +} diff --git a/crates/botloftd/tests/delete_messages.rs b/crates/botloftd/tests/delete_messages.rs new file mode 100644 index 00000000..a8e92835 --- /dev/null +++ b/crates/botloftd/tests/delete_messages.rs @@ -0,0 +1,143 @@ +//! What the other bots hear when a bot is deleted (spec 7.6): notices +//! about the tasks it leaves, and its messages that still waited. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use common::Client; +use common::bots::{text_of, two_bots}; +use serde_json::{Value, json}; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +#[tokio::test] +async fn the_other_bots_hear_about_the_tasks_a_deleted_bot_leaves() { + let mut c = two_bots().await; + let ask = |to: &str| json!({ "to": to, "body": "Please help.", "kind": "task" }); + let owed = c + .writer + .tool("send_message", ask("lead")) + .await + .expect("task for the lead"); + let asked = c + .lead + .tool("send_message", ask("writer")) + .await + .expect("task for the writer"); + let (owed, asked) = ( + owed["task_id"].as_str().expect("id").to_owned(), + asked["task_id"].as_str().expect("id").to_owned(), + ); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + c.writer_process.wait_lines(2).await; + + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + let lines = c.writer_process.wait_lines(4).await; + assert_eq!( + text_of(&lines[2]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {owed} for @lead will not be done: the \ + owner deleted @lead. Get it done another way, or tell the owner what is missing." + ) + ); + assert_eq!( + text_of(&lines[3]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {asked} from @lead no longer needs a \ + result: the owner deleted @lead. Stop working on it." + ) + ); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + let gone = c + .writer + .tool( + "complete_task", + json!({ "task_id": asked, "result": "Done anyway." }), + ) + .await + .expect_err("the task went with the bot"); + assert!(gone.contains("does not exist"), "{gone}"); + + // What the lead wrote stays in the writer's chat, without a sender. + let writer = bot_named(&mut c.app, "Writer").await; + let kept = c + .app + .call("messages.list", json!({ "botId": writer["id"] })) + .await + .expect("messages"); + let kept: Vec<_> = kept.as_array().expect("list").iter().rev().collect(); + assert_eq!(kept.len(), 4); + for message in &kept[..2] { + assert_eq!(message["fromKind"], "bot"); + assert_eq!(message["fromBotId"], Value::Null); + assert_eq!(message["taskId"], Value::Null); + } + assert_eq!(kept[1]["body"], "The draft is in shared/."); + assert_eq!(kept[2]["fromKind"], "system"); +} + +#[tokio::test] +async fn a_message_still_waiting_arrives_from_a_deleted_bot() { + let mut c = two_bots().await; + let writer = bot_named(&mut c.app, "Writer").await; + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": true }), + ) + .await + .expect("pause"); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": false }), + ) + .await + .expect("resume"); + // The lead's, the writer's first and the one the writer resumes in. + let process = c.t.runtime.process(3).await; + c.t.until_state(&writer, BotState::Idle).await; + c.t.clock.advance(Duration::from_secs(5)); + let lines = process.wait_lines(1).await; + assert_eq!( + text_of(&lines[0]), + "[botloft] from a deleted bot · crew Ops\n\nThe draft is in shared/." + ); +} diff --git a/docs/spec.md b/docs/spec.md index 062638e2..9c6ea0e7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,6 +117,8 @@ Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer **Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. +**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: a confirmação do app diz que elas ficam e mostra o caminho (15.1), e o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. + Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. Slug: ASCII minúsculo com hífens, acentos transliterados (`Revisão` -> `revisao`), no máximo 32 caracteres, nunca um nome de dispositivo do Windows (`con`, `lpt1`...) e nunca `shared` para bot. Colisão ganha sufixo (`docs-2`), inclusive com slug de item arquivado ou pasta que já exista no disco. O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha renomeações e é único entre os bots ativos da crew; um nome que gere handle já usado é recusado com erro de validação. @@ -267,6 +269,21 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. - Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual. +### 7.6 Arquivar e excluir + +**Arquivar** (`bots.archive`, `crews.archive`) tira o bot ou a crew do app e guarda tudo no banco: o processo para, o token é revogado, as rotinas são arquivadas junto (20.3) e o perfil do navegador é apagado (21.2). + +**Excluir** (`bots.delete`, `crews.delete`) apaga do banco, de vez, e vale para ativos e arquivados. Não tem volta, e nenhuma pasta é apagada (5). + +- **O bot para na hora.** O processo é morto sem esperar o turno, o token deixa de valer, o navegador fecha e o perfil dele é apagado, e os pedidos de permissão abertos caem. O supervisor esquece o bot: o que o processo ainda imprimir é ignorado, e uma reconciliação que tinha lido o bot antes não o sobe de novo. +- **Sai do banco** (12), numa transação: o bot, o chat dele, as aprovações, as messages que ele **recebeu** (com deliveries e o registro dos anexos), as rotinas com as execuções, os sites liberados no navegador e as tasks que ele pediu ou recebeu. Se era o chefe, a crew fica sem chefe (10.2). +- **Fica com os outros bots:** as messages que o excluído **mandou** continuam no chat de quem recebeu e na timeline da crew, sem remetente (`fromBotId: null`) e sem task. Uma que ainda esperava na fila chega com `from a deleted bot` e sem instrução de resposta (9.3). +- **Tasks em aberto** (`open` ou `expired`) somem com o bot, e o outro lado é avisado pelo daemon (`from Botloft`), se ainda está ativo: quem pediu uma task ao excluído lê que ela não vai ser feita; quem fazia uma task para o excluído lê que ninguém mais espera o resultado. Um `complete_task` dela responde que a task não existe. +- **Excluir uma crew** exclui todos os bots dela, ativos e arquivados, com tudo acima, e a crew. Ninguém é avisado: não sobra ninguém. +- **Avisos ao app:** `bot.deleted {botId, crewId}` e `crew.deleted {crewId}` (11.3). O app tira do store o bot (ou a crew com os bots) e o que era dele: rotinas, tasks, deliveries e o navegador. As outras coisas que mudam saem pelos avisos de sempre (`crew.changed` sem chefe, `message.created` dos avisos de task). +- **No app** (15.1): Excluir fica no menu do bot e no menu da crew, depois de Arquivar, com uma confirmação que diz o que sai e o que fica. +- **Próximos passos:** na confirmação, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra, então ele só oferece excluir os ativos (o daemon já aceita os dois). + ## 8. Chat O chat de um bot é a sequência de itens que o daemon monta a partir do que entra no stdin e do que sai no stdout. Ele fica no banco (`chat_items`), é paginado pelo app e é a única visão da conversa: não há terminal. @@ -373,7 +390,8 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - Nota de outro bot: primeira linha `[botloft] from @revisor · crew Exemplo` e só a instrução de resposta. - Resultado de task: `· result of task tsk_... · done` (ou `failed`) e a instrução de resposta. -- Aviso do daemon (task vencida): `from Botloft` e o texto do aviso, sem instrução. +- Aviso do daemon (task vencida, task de um bot excluído): `from Botloft` e o texto do aviso, sem instrução. +- Message de um bot que o dono excluiu enquanto ela esperava na fila (7.6): `from a deleted bot`, sem instrução de resposta, porque não há a quem responder. - Rotina: `routine "" · scheduled ()` e o aviso de que ninguém está olhando (20.5). - O prazo é relativo (`due in 45 min`, `due in 2 h`, `overdue`) e calculado na hora do envio: o bot não sabe a hora atual, e o app mostra o horário absoluto a partir de `deadline_at`. @@ -386,6 +404,7 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - `complete_task` só vale para quem recebeu a task, com status `open` ou `expired`. Grava o resultado e, na mesma transação, cria a message `result` para o solicitante. - Task vencida vira `expired` no ciclo do courier, e o solicitante recebe um aviso do daemon (`from Botloft`). Ela continua aceitando resultado atrasado. - `cancelled` fica reservado: nenhuma tool cancela task no MVP. +- Excluir um bot apaga as tasks que ele pediu ou recebeu e avisa o outro lado das que estavam em aberto (7.6). ### 9.5 Anexos @@ -451,7 +470,7 @@ A aprovação só abre se `tool_use_id` for de uma ferramenta em `running` no ch ### 10.2 Chefe e sugestões de bots -Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e esse bot juntos, e `Crew.leadBotId` aponta para ele. O app escreve nome e papel no idioma do dono ("Chefe", "Lidera a equipe: planeja o trabalho, sugere bots novos e distribui as tarefas"); as instruções são o objetivo que o dono escreveu para a crew. O chefe é gravado antes de o supervisor poder subi-lo, então o primeiro start já lê que lidera. O dono troca o chefe (`crews.setLead`) ou deixa a crew sem nenhum; o chefe antigo e o novo têm as regras regravadas e reiniciam quando nada estiver em andamento (7.4). Arquivar o chefe deixa a crew sem chefe. Crews de antes não têm chefe até o dono escolher um. +Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e esse bot juntos, e `Crew.leadBotId` aponta para ele. O app escreve nome e papel no idioma do dono ("Chefe", "Lidera a equipe: planeja o trabalho, sugere bots novos e distribui as tarefas"); as instruções são o objetivo que o dono escreveu para a crew. O chefe é gravado antes de o supervisor poder subi-lo, então o primeiro start já lê que lidera. O dono troca o chefe (`crews.setLead`) ou deixa a crew sem nenhum; o chefe antigo e o novo têm as regras regravadas e reiniciam quando nada estiver em andamento (7.4). Arquivar ou excluir o chefe deixa a crew sem chefe. Crews de antes não têm chefe até o dono escolher um. - **Regras do chefe:** além das de todo bot, uma seção "You lead this crew": planejar, dividir o trabalho em partes que rodam em paralelo e distribuí-las com `send_message` (`kind: "task"`); conferir os resultados antes de responder ao dono; quando faltar um especialista, sugerir um bot com `suggest_bot`; escolher o modelo pelo trabalho (`haiku` para o simples e repetitivo, `sonnet` para quase tudo, `opus` ou `fable` só para o raciocínio mais difícil, lembrando que gastam mais do plano); manter a crew pequena; e contar ao dono o que cada bot faz, para ele acompanhar e falar com cada um no chat dele. Os outros bots leem que, se a crew precisar de outro bot, devem pedir ao chefe, que o `crew_roster` marca com `chief`. - **`suggest_bot`:** aparece para todos os bots, mas só o chefe pode usar; os outros recebem um erro que manda pedir ao chefe. Antes de incomodar o dono, o daemon confere os campos (os de um bot, mais o limite de instruções e o porquê), que o nome está livre na crew e que a crew tem menos de `max_per_crew` bots ativos. Um pedido impossível volta como erro ao chefe. @@ -485,6 +504,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) | | `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) | | `crews.archive` | `crewId` | `Crew` | +| `crews.delete` | `crewId` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela (7.6) | | `bots.list` | `crewId?` | `Bot[]` | | `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | | `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` | @@ -493,6 +513,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.setModel` | `botId, model` (`default`, `fable`, `opus`, `sonnet`, `haiku`) | `Bot`; o bot reinicia no novo modelo quando nada estiver em andamento (7.4) | | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | +| `bots.delete` | `botId` | `{botId, crewId}`; exclui o bot, ativo ou arquivado (7.6) | | `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | | `approvals.answer` | `approvalId, allow, note?, input?` (`input`: a sugestão de bot como o dono a deixou, 10.2) | `Approval` | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | @@ -512,7 +533,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `crew.changed`, `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). +`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). ### 11.4 Erros @@ -524,11 +545,11 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `-32004` | validação | | `-32005` | runtime indisponível (claude ausente ou versão antiga) | -Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-32600` (request inválido), `-32601` (método desconhecido), `-32602` (params inválidos) e `-32603` (erro interno; a mensagem não traz corpo de mensagem nem token). `crews.archive` e `bots.archive` são idempotentes. +Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-32600` (request inválido), `-32601` (método desconhecido), `-32602` (params inválidos) e `-32603` (erro interno; a mensagem não traz corpo de mensagem nem token). `crews.archive` e `bots.archive` são idempotentes. `crews.delete` e `bots.delete` não: o que já foi excluído não existe mais, e a segunda chamada dá `-32002`, como qualquer outro método com esse id. ## 12. Dados (SQLite) -Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). +Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`approvals`, `chat_items`, `browser_sites`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. | Tabela | Colunas principais | |---|---| @@ -573,7 +594,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Processos órfãos | Job Object por bot (seção 7.3) | | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | -| Arquivo em uso | arquivar bot só apaga workspace depois do processo encerrado; retentar exclusão se bloqueado | +| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos | | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | | Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) | @@ -620,7 +641,7 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. - **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. - **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha. @@ -631,6 +652,7 @@ Layout, como um app de mensagens: - **Área principal com uma crew:** a timeline (messages entre os bots e do dono) e as tasks. Embaixo do nome, "Trabalha em ", que abre a pasta no Explorer; o menu da crew tem "Abrir pasta de trabalho" e "Mudar pasta de trabalho…", que abre o seletor de pastas do Windows e confirma antes de mudar, avisando que cada bot reinicia quando terminar o que está fazendo. - **Nova crew:** nome; "Para que é esta equipe?", o objetivo que vira as instruções do chefe, com a explicação de que a equipe começa com um Chefe que planeja o trabalho e sugere os bots de que precisa; "Pasta de trabalho", com "Escolher pasta…" (o seletor do Windows; sem escolha, "Uma pasta nova dentro do Botloft", a `shared\`); e "Modelo do Chefe". Criada, o app abre o chat do chefe. - **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe". +- **Excluir** (7.6): "Excluir bot" é a última ação do menu do bot (no cabeçalho e no clique direito) e "Excluir equipe" a última do menu da crew, as duas em vermelho, depois de Arquivar. A confirmação leva o nome no título ("Excluir Scout?") e diz, em palavras simples, o que sai e o que fica: o bot para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte, e não dá para desfazer; se ele é o chefe, que a equipe fica sem chefe; e, num quadro à parte, que a pasta dele continua no computador, com o caminho para copiar. A da crew diz quantos bots param e saem com ela, e mostra a pasta de trabalho, lembrando que a pasta de cada bot também fica. Confirmado, o app tira o bot ou a crew da tela sem esperar o aviso do daemon; se falhar, um aviso diz que não foi possível e nada muda. Excluído o bot aberto, fica a página da crew; excluída a crew aberta, nenhuma fica escolhida (sem crews, a tela de boas-vindas). Uma message de um bot que saiu, arquivado ou excluído, aparece na conversa e na timeline como de "um bot que não está mais na equipe". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. - **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. @@ -912,7 +934,7 @@ Cada disparo vira uma `routine_run`, com o horário marcado (`scheduled_for`), u - **Sobreposição:** o horário chega com a execução anterior ainda `queued` (o bot está lento, parado por limite de uso, sem login ou fora do ar). - `skip` (padrão): registra `skipped` com `reason: overlap`. Um bot lento ou fora do ar não acumula pedidos repetidos. - `queue`: grava mesmo assim, mas só uma execução espera atrás da aberta; as outras viram `skipped`. -- **Bot ou crew pausados:** o horário vira `skipped` (`bot_paused`); a rotina não guarda pedidos para quando voltar. Bot ou crew arquivados: a rotina é arquivada junto. +- **Bot ou crew pausados:** o horário vira `skipped` (`bot_paused`); a rotina não guarda pedidos para quando voltar. Bot ou crew arquivados: a rotina é arquivada junto. Excluídos: a rotina e as execuções são apagadas (7.6). - **Rodar agora:** `routines.runNow` cria uma execução fora de hora (`scheduled_for` = agora), sem mexer no próximo horário, e vale a mesma regra de sobreposição. ### 20.4 Horários perdidos @@ -1023,7 +1045,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - Comando: `msedge.exe --headless=new --remote-debugging-port=0 --user-data-dir=\browsers\ --no-first-run --no-default-browser-check --mute-audio --disable-extensions --disable-sync about:blank`. Sem `--disable-extensions`, um perfil novo recebe as extensões instaladas para todo o computador, e elas abrem abas próprias no navegador do bot (visto com o Edge 154). Com a porta 0, o Edge escolhe uma porta livre em 127.0.0.1 e a escreve com o caminho do WebSocket em `DevToolsActivePort`, na pasta do perfil (visto com o Edge 154); o daemon apaga o arquivo antigo antes de subir e espera o novo por até 10 s. - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). - Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. -- **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar o bot fecha o navegador e apaga o perfil. +- **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. - **Fechar:** depois de `[browser] idle_minutes` sem tool do bot e sem ninguém assistindo; quando o bot ou a crew pausa; com `browser_close`. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. - Se o processo cai ou o WebSocket fecha, o estado vira `closed` e a próxima tool sobe de novo.