From d11497f7a90a721d5ef18569e2cac8226c4197b1 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:56:15 -0300 Subject: [PATCH 1/9] feat(app): delete a bot or a crew from its menu Delete is the last action of the bot's menu (header and right-click) and of the crew's menu. The confirmation names the bot or crew and says what goes (the conversation, routines and tasks, for good) and what stays (the folder, with its path). A message from a bot that left, archived or deleted, reads as from a bot no longer in the crew. --- app/src/features/bots/botActions.tsx | 24 +++- app/src/features/bots/delete.test.tsx | 163 +++++++++++++++++++++++ app/src/features/chat/InboundRow.tsx | 2 +- app/src/features/crews/CrewView.tsx | 21 ++- app/src/features/crews/botMenu.test.tsx | 1 + app/src/features/messages/MessageRow.tsx | 2 +- app/src/i18n/en/bots.ts | 10 ++ app/src/i18n/en/chat.ts | 2 +- app/src/i18n/en/crews.ts | 9 ++ app/src/i18n/en/messages.ts | 2 +- app/src/i18n/es/bots.ts | 11 ++ app/src/i18n/es/chat.ts | 2 +- app/src/i18n/es/crews.ts | 9 ++ app/src/i18n/es/messages.ts | 2 +- app/src/i18n/pt-BR/bots.ts | 11 ++ app/src/i18n/pt-BR/chat.ts | 2 +- app/src/i18n/pt-BR/crews.ts | 9 ++ app/src/i18n/pt-BR/messages.ts | 2 +- app/src/ui/KeptFolder.tsx | 13 ++ docs/spec.md | 8 +- 20 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 app/src/features/bots/delete.test.tsx create mode 100644 app/src/ui/KeptFolder.tsx diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 246b2c9d..86b33021 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -1,15 +1,16 @@ -import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react"; +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" | "fresh" | null; +type Open = "edit" | "archive" | "delete" | "fresh" | null; /** * What the owner can do with a bot from a menu, and the dialogs those @@ -26,6 +27,7 @@ export function useBotActions( 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); @@ -54,6 +56,7 @@ export function useBotActions( 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 = @@ -85,6 +88,23 @@ export function useBotActions( > {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/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx index f26c0988..a8be2fb9 100644 --- a/app/src/features/crews/botMenu.test.tsx +++ b/app/src/features/crews/botMenu.test.tsx @@ -40,6 +40,7 @@ describe("a bot's right-click menu", () => { "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(); 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 47896821..febb9a04 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -48,6 +48,7 @@ export const bots = { 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", @@ -55,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?", @@ -67,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 ef9fa0e4..e1d45944 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -44,6 +44,7 @@ export const bots: Messages["bots"] = { 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", @@ -51,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?", @@ -63,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 add69c70..6077acef 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -44,6 +44,7 @@ export const bots: Messages["bots"] = { 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", @@ -51,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?", @@ -63,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/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/docs/spec.md b/docs/spec.md index c78bbfe8..9c6ea0e7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,7 +117,7 @@ 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: 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. +**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. @@ -281,7 +281,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **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). -- **Próximos passos:** Excluir nos menus do app, com uma confirmação que diz o que sai e o que fica; nela, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra. +- **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 @@ -640,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. 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), 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. +- **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. @@ -651,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. From f320f6bf9f7fc80ce37609cf7bd353dc9ecb34c3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:05:54 -0300 Subject: [PATCH 2/9] feat(daemon): archive.list, what the owner archived The other lists leave archived crews and bots out, so nothing showed them. archive.list returns both, the latest archived first, for the app to list and delete. --- app/src/lib/fake.ts | 4 ++ app/src/lib/protocol.gen.ts | 8 +++ crates/botloft-core/src/protocol/export.rs | 2 + crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-core/src/protocol/model.rs | 11 ++++ crates/botloftd/src/rpc/dispatch.rs | 5 +- crates/botloftd/src/service/archive.rs | 38 +++++++++++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/tests/archive.rs | 75 ++++++++++++++++++++++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 crates/botloftd/src/service/archive.rs create mode 100644 crates/botloftd/tests/archive.rs diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 82e5f6ce..43685160 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -221,6 +221,10 @@ export class FakeBotloft implements BotloftApi { }; return this.settings; }, + "archive.list": () => ({ + crews: [...this.crews.values()].filter((crew) => crew.archivedAt !== null), + bots: [...this.bots.values()].filter((bot) => bot.archivedAt !== null), + }), ...crewHandlers(this), ...botHandlers(this), ...this.chat.handlers(), diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 5b35c1d4..b7c079cd 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -372,6 +372,13 @@ export type BotDeleted = { botId: BotId, crewId: CrewId, }; */ export type CrewDeleted = { crewId: CrewId, }; +/** + * What the owner archived (`archive.list`): out of the app, kept in the + * database, and still there to delete (spec 7.6). `bots` has every + * archived bot, those of archived crews too. + */ +export type Archive = { crews: Array, bots: Array, }; + /** * Who wrote a message. */ @@ -1005,6 +1012,7 @@ export interface RpcMethods { "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; "crews.delete": { params: CrewIdParams; result: CrewDeleted }; + "archive.list": { params: undefined; result: Archive }; "bots.list": { params: BotsListParams; result: Array }; "bots.create": { params: BotsCreateParams; result: Bot }; "bots.update": { params: BotsUpdateParams; result: Bot }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index c7a302a7..482bc3e2 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -127,6 +127,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -232,6 +233,7 @@ fn export_bindings() { &out.name::(), &crew_deleted, ); + out.method(method::ARCHIVE_LIST, "undefined", &out.name::()); out.method(method::BOTS_LIST, &out.name::(), &bots); out.method(method::BOTS_CREATE, &out.name::(), &bot); out.method(method::BOTS_UPDATE, &out.name::(), &bot); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 60b971bd..228f2afe 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -90,6 +90,7 @@ pub mod method { pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; pub const CREWS_DELETE: &str = "crews.delete"; + pub const ARCHIVE_LIST: &str = "archive.list"; pub const BOTS_LIST: &str = "bots.list"; pub const BOTS_CREATE: &str = "bots.create"; pub const BOTS_UPDATE: &str = "bots.update"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 169ca45f..e6d0810a 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -179,3 +179,14 @@ pub struct BotDeleted { pub struct CrewDeleted { pub crew_id: CrewId, } + +/// What the owner archived (`archive.list`): out of the app, kept in the +/// database, and still there to delete (spec 7.6). `bots` has every +/// archived bot, those of archived crews too. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Archive { + pub crews: Vec, + pub bots: Vec, +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 9d37c7e0..4c707c0a 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, delete, deliveries, files, lead, messages, - models, modes, routines, screens, settings, tasks, + self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, + messages, models, modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -29,6 +29,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result 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::ARCHIVE_LIST => reply(archive::list(daemon)), 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)?)), diff --git a/crates/botloftd/src/service/archive.rs b/crates/botloftd/src/service/archive.rs new file mode 100644 index 00000000..f130158e --- /dev/null +++ b/crates/botloftd/src/service/archive.rs @@ -0,0 +1,38 @@ +//! `archive.list`: what the owner archived, for the app to show and delete +//! (spec 7.6). The other lists leave archived crews and bots out. + +use std::cmp::Reverse; +use std::collections::HashMap; + +use botloft_core::protocol::Archive; + +use super::{ApiResult, bots, crews}; +use crate::state::Daemon; + +/// Archived crews and bots, the most recently archived first. A bot of an +/// archived crew is listed too, so the app can count them. +pub fn list(daemon: &Daemon) -> ApiResult { + let store = daemon.store(); + let crews: HashMap<_, _> = store + .crews(true)? + .into_iter() + .map(|crew| (crew.id.clone(), crews::present(daemon, crew))) + .collect(); + let mut bots: Vec<_> = store + .bots(None, true)? + .into_iter() + .filter(|bot| bot.archived_at.is_some()) + .filter_map(|bot| { + let crew = crews.get(&bot.crew_id)?; + Some(bots::to_protocol(daemon, &store, crew, bot)) + }) + .collect(); + // Ids grow with time, so they settle what was archived together. + bots.sort_by_key(|bot| Reverse((bot.archived_at, bot.id.clone()))); + let mut crews: Vec<_> = crews + .into_values() + .filter(|crew| crew.archived_at.is_some()) + .collect(); + crews.sort_by_key(|crew| Reverse((crew.archived_at, crew.id.clone()))); + Ok(Archive { crews, bots }) +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index a3fc2343..7fde3679 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -1,6 +1,7 @@ //! What the RPC methods do, independent of JSON-RPC. Each operation runs //! under the store lock, so checks and writes cannot interleave. +pub mod archive; pub mod attachments; pub mod bots; pub mod chat; diff --git a/crates/botloftd/tests/archive.rs b/crates/botloftd/tests/archive.rs new file mode 100644 index 00000000..c062ecfc --- /dev/null +++ b/crates/botloftd/tests/archive.rs @@ -0,0 +1,75 @@ +//! `archive.list` (spec 7.6): what the owner archived, which the other +//! lists leave out, until it is deleted. + +mod common; + +use common::TestDaemon; +use serde_json::{Value, json}; + +async fn archive(app: &mut common::Client) -> Value { + app.call("archive.list", json!(null)) + .await + .expect("archive") +} + +fn names(list: &Value) -> Vec<&str> { + list.as_array() + .expect("list") + .iter() + .map(|item| item["name"].as_str().expect("name")) + .collect() +} + +#[tokio::test] +async fn the_archive_lists_what_was_archived_until_it_is_deleted() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let ops = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let docs = app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("crew"); + let mut bots = Vec::new(); + for (crew, name) in [(&ops, "Scout"), (&ops, "Writer"), (&docs, "Editor")] { + let bot = json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" }); + bots.push(app.call("bots.create", bot).await.expect("bot")); + } + let empty = archive(&mut app).await; + assert_eq!(empty, json!({ "crews": [], "bots": [] })); + + app.call("bots.archive", json!({ "botId": bots[0]["id"] })) + .await + .expect("archive a bot"); + app.call("crews.archive", json!({ "crewId": docs["id"] })) + .await + .expect("archive a crew"); + + let listed = archive(&mut app).await; + assert_eq!(names(&listed["crews"]), ["Docs"]); + // The archived crew's bot comes too, the latest archived first. + assert_eq!(names(&listed["bots"]), ["Editor", "Scout"]); + let scout = &listed["bots"][1]; + assert_eq!(scout["state"], "archived"); + assert!(scout["archivedAt"].as_i64().is_some()); + let folder = t.paths.bot_workspace("ops", "scout"); + assert_eq!(scout["workspace"], folder.to_string_lossy().as_ref()); + let shared = t.paths.shared_dir("docs"); + assert_eq!( + listed["crews"][0]["workFolder"], + shared.to_string_lossy().as_ref() + ); + + app.call("crews.delete", json!({ "crewId": docs["id"] })) + .await + .expect("delete the crew"); + app.call("bots.delete", json!({ "botId": bots[0]["id"] })) + .await + .expect("delete the bot"); + assert_eq!(archive(&mut app).await, empty); + // What was never archived is where it was. + let active = app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(names(&active), ["Writer"]); +} From 597a72fcca12978fc68fa2b8c05d98a67b7f81f4 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:05:55 -0300 Subject: [PATCH 3/9] feat(app): archived bots and crews in Settings, each with Delete Settings gets an Archived part: the crews and the bots the owner archived, which the rest of the app does not show, with the same delete confirmation as the menus. The confirmation is now a component of its own (DeleteBot, DeleteCrew) and says "leaves" instead of "stops now" for what was already stopped. --- app/src/features/account/ArchivedSettings.tsx | 140 ++++++++++++++++++ app/src/features/account/SettingsDialog.tsx | 9 +- app/src/features/account/archived.test.tsx | 90 +++++++++++ app/src/features/bots/DeleteBot.tsx | 45 ++++++ app/src/features/bots/botActions.tsx | 20 +-- app/src/features/crews/CrewView.tsx | 19 +-- app/src/features/crews/DeleteCrew.tsx | 42 ++++++ app/src/i18n/en/account.ts | 11 ++ app/src/i18n/en/bots.ts | 5 +- app/src/i18n/en/crews.ts | 5 +- app/src/i18n/es/account.ts | 10 ++ app/src/i18n/es/bots.ts | 4 +- app/src/i18n/es/crews.ts | 4 +- app/src/i18n/pt-BR/account.ts | 10 ++ app/src/i18n/pt-BR/bots.ts | 4 +- app/src/i18n/pt-BR/crews.ts | 4 +- docs/spec.md | 7 +- 17 files changed, 377 insertions(+), 52 deletions(-) create mode 100644 app/src/features/account/ArchivedSettings.tsx create mode 100644 app/src/features/account/archived.test.tsx create mode 100644 app/src/features/bots/DeleteBot.tsx create mode 100644 app/src/features/crews/DeleteCrew.tsx diff --git a/app/src/features/account/ArchivedSettings.tsx b/app/src/features/account/ArchivedSettings.tsx new file mode 100644 index 00000000..7666c76d --- /dev/null +++ b/app/src/features/account/ArchivedSettings.tsx @@ -0,0 +1,140 @@ +// Settings, "Archived": the bots and crews the owner archived, which the +// rest of the app does not show, with a way to delete each for good +// (spec 7.6). + +import { Users } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { fromNow } from "../../lib/format"; +import type { Archive, Bot, Crew } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { BotAvatar } from "../bots/BotAvatar"; +import { DeleteBot } from "../bots/DeleteBot"; +import { DeleteCrew } from "../crews/DeleteCrew"; +import { Section } from "./settingsParts"; + +type Asking = { bot: Bot } | { crew: Crew } | null; + +export function ArchivedSettings() { + const s = useT().account.settings; + const api = useApi(); + const active = useApp((state) => state.crews); + const [archive, setArchive] = useState(null); + const [failed, setFailed] = useState(null); + const [asking, setAsking] = useState(null); + + useEffect(() => { + let alive = true; + api.call("archive.list").then( + (list) => alive && setArchive(list), + (error) => alive && setFailed(errorText(error)), + ); + return () => { + alive = false; + }; + }, [api]); + + if (failed) { + return ( +
    + + {failed} + +
    + ); + } + if (!archive) { + return
    {null}
    ; + } + + // A bot of an archived crew goes with its crew; the others are listed. + const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id); + const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId)); + const forget = (gone: Asking) => + setArchive({ + crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)), + bots: archive.bots.filter( + (bot) => + !(gone && "bot" in gone && gone.bot.id === bot.id) && + !(gone && "crew" in gone && gone.crew.id === bot.crewId), + ), + }); + + return ( +
    +

    {s.archivedIntro}

    + {archive.crews.length + bots.length === 0 ? ( +

    {s.archivedEmpty}

    + ) : ( +
      + {archive.crews.map((crew) => ( + + + + } + name={crew.name} + line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))} + onDelete={() => setAsking({ crew })} + /> + ))} + {bots.map((bot) => ( + } + name={bot.name} + line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))} + onDelete={() => setAsking({ bot })} + /> + ))} +
    + )} + {asking && "bot" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} + {asking && "crew" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} +
    + ); +} + +function Row({ + icon, + name, + line, + onDelete, +}: { + icon: ReactNode; + name: string; + line: string; + onDelete(): void; +}) { + const s = useT().account.settings; + return ( +
  • + {icon} +
    +

    {name}

    +

    {line}

    +
    + +
  • + ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 99bd5eba..c5128067 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,8 +1,10 @@ // Settings (spec 15.1), in parts listed on the left: general (what // Botloft does in the background, the language), the chat, notifications, -// the look, and the versions for when something needs reporting. +// the look, what was archived, and the versions for when something needs +// reporting. import { + Archive, Bell, Info, type LucideIcon, @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog"; import { AboutSettings } from "./AboutSettings"; import { AlertsSettings } from "./AlertsSettings"; import { AppearanceSettings } from "./AppearanceSettings"; +import { ArchivedSettings } from "./ArchivedSettings"; import { ChatSettings } from "./ChatSettings"; import { GeneralSettings } from "./GeneralSettings"; -type Page = "general" | "chat" | "alerts" | "appearance" | "about"; +type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about"; const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "general", icon: SlidersHorizontal }, { id: "chat", icon: MessageSquare }, { id: "alerts", icon: Bell }, { id: "appearance", icon: Palette }, + { id: "archived", icon: Archive }, { id: "about", icon: Info }, ]; @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { {page === "chat" && } {page === "alerts" && } {page === "appearance" && } + {page === "archived" && } {page === "about" && } diff --git a/app/src/features/account/archived.test.tsx b/app/src/features/account/archived.test.tsx new file mode 100644 index 00000000..edcdea9c --- /dev/null +++ b/app/src/features/account/archived.test.tsx @@ -0,0 +1,90 @@ +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, renderApp } from "../../test/app"; + +afterEach(cleanup); + +/** Opens Settings on its "Archived" part. */ +async function openArchived() { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" })); + return dialog; +} + +/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */ +async function someArchived() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const docs = fake.addCrew("Docs"); + const scout = fake.addBot(ops.id, "Scout"); + fake.addBot(ops.id, "Writer"); + const editor = fake.addBot(docs.id, "Editor"); + await fake.call("bots.archive", { botId: scout.id }); + await fake.call("crews.archive", { crewId: docs.id }); + renderApp(fake); + await crewOpened("Ops"); + return { fake, ops, docs, scout, editor }; +} + +describe("settings, archived", () => { + test("lists what was archived, which the app shows nowhere else", async () => { + await someArchived(); + const dialog = await openArchived(); + const list = await within(dialog).findByRole("list", { name: "Archived" }); + const rows = within(list).getAllByRole("listitem"); + expect(rows.map((row) => row.textContent)).toEqual([ + "DocsCrew · 1 bot · archived nowDelete", + "ScoutBot in Ops · archived nowDelete", + ]); + // A bot of an archived crew goes with its crew, not on its own. + expect(within(list).queryByText("Editor")).toBeNull(); + expect(dialog.textContent).toContain("Botloft still keeps their conversations"); + }); + + test("deletes an archived bot, saying it leaves rather than stops", async () => { + const { fake, scout } = await someArchived(); + const dialog = await openArchived(); + fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" })); + const confirm = screen.getByRole("dialog", { name: "Delete Scout?" }); + expect(confirm.textContent).toContain("Scout leaves Botloft for good"); + expect(confirm.textContent).not.toContain("stops now"); + expect(within(confirm).getByText(scout.workspace)).toBeDefined(); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull()); + expect(fake.bots.has(scout.id)).toBe(false); + expect(within(dialog).getByText("Docs")).toBeDefined(); + }); + + test("deletes an archived crew with its bots, then has nothing left", async () => { + const { fake, docs, scout, editor } = await someArchived(); + await fake.call("bots.delete", { botId: scout.id }); + const dialog = await openArchived(); + fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" })); + const confirm = screen.getByRole("dialog", { name: "Delete Docs?" }); + expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good"); + expect(within(confirm).getByText(docs.workFolder)).toBeDefined(); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" })); + expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined(); + expect(fake.crews.has(docs.id)).toBe(false); + expect(fake.bots.has(editor.id)).toBe(false); + }); + + test("says so when nothing is archived, or when the list cannot be read", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const dialog = await openArchived(); + expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined(); + + fake.failNext("archive.list", new RpcError(-32603, "internal error")); + fireEvent.click(within(dialog).getByRole("tab", { name: "About" })); + fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" })); + const alert = await within(dialog).findByRole("alert"); + expect(alert.textContent).toContain("Could not load what is archived"); + }); +}); diff --git a/app/src/features/bots/DeleteBot.tsx b/app/src/features/bots/DeleteBot.tsx new file mode 100644 index 00000000..90310386 --- /dev/null +++ b/app/src/features/bots/DeleteBot.tsx @@ -0,0 +1,45 @@ +import { useT } from "../../i18n"; +import type { Bot } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; +import { attempt } from "../../ui/toast"; + +/** + * Asks before deleting a bot for good (spec 7.6), saying what goes and + * what stays. `chiefOf` is the crew it leads, if it leads one. + */ +export function DeleteBot({ + bot, + chiefOf, + onClose, + onDeleted, +}: { + bot: Bot; + chiefOf?: string | undefined; + onClose(): void; + onDeleted?(): void; +}) { + const words = useT().bots.header; + const api = useApi(); + const dropBot = useApp((state) => state.dropBot); + return ( + + attempt(words.failed.delete, async () => { + dropBot((await api.call("bots.delete", { botId: bot.id })).botId); + onDeleted?.(); + }) + } + > +

    + {words.deleteConfirm.removed(bot.name, bot.archivedAt === null)} + {chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`} +

    + {words.deleteConfirm.kept(bot.name)} +
    + ); +} diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 86b33021..58524ce5 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -4,11 +4,11 @@ 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"; +import { DeleteBot } from "./DeleteBot"; type Open = "edit" | "archive" | "delete" | "fresh" | null; @@ -27,7 +27,6 @@ export function useBotActions( 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); @@ -89,22 +88,7 @@ export function useBotActions( {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/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index 65f6a413..b322903a 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -18,7 +18,6 @@ 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"; @@ -29,6 +28,7 @@ import { CrewRoutines } from "../routines/RoutineList"; import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; import { CrewDialog } from "./CrewDialog"; +import { DeleteCrew } from "./DeleteCrew"; type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; type Pane = "bots" | "timeline" | "tasks" | "routines"; @@ -39,7 +39,6 @@ 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"); @@ -192,21 +191,7 @@ 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} -
    - )} + {open === "delete" && } ); } diff --git a/app/src/features/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx new file mode 100644 index 00000000..ef786118 --- /dev/null +++ b/app/src/features/crews/DeleteCrew.tsx @@ -0,0 +1,42 @@ +import { useT } from "../../i18n"; +import type { Crew } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; +import { attempt } from "../../ui/toast"; + +/** + * Asks before deleting a crew for good (spec 7.6), saying what goes and + * what stays. `bots` is how many bots go with it. + */ +export function DeleteCrew({ + crew, + bots, + onClose, + onDeleted, +}: { + crew: Crew; + bots: number; + onClose(): void; + onDeleted?(): void; +}) { + const words = useT().crews.view; + const api = useApi(); + const dropCrew = useApp((state) => state.dropCrew); + return ( + + attempt(words.failed.delete, async () => { + dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId); + onDeleted?.(); + }) + } + > +

    {words.deleteBody(crew.name, bots, crew.archivedAt === null)}

    + {words.deleteKept} +
    + ); +} diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index c4ba71d8..2bc6745c 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -87,6 +87,17 @@ export const account = { size: "Size", sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.", language: "Language", + archived: "Archived", + archivedIntro: + "Bots and crews you archived are stopped and out of sight, and Botloft still keeps their conversations. Delete one to remove it for good.", + archivedEmpty: "Nothing is archived.", + archivedLoadFailed: "Could not load what is archived", + /** `when` is relative, like "2 days ago". */ + archivedCrew: (bots: number, when: string) => + `Crew · ${bots === 1 ? "1 bot" : `${bots} bots`} · archived ${when}`, + archivedBot: (crew: string, when: string) => `Bot in ${crew} · archived ${when}`, + deleteArchived: "Delete", + deleteArchivedOne: (name: string) => `Delete ${name}`, about: "About", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index febb9a04..f7fa547a 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -72,8 +72,9 @@ export const bots = { 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.`, + /** `running` is false for an archived bot, which stopped long ago. */ + removed: (name: string, running: boolean) => + `${name} ${running ? "stops now and leaves" : "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:`, }, diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 45fc6940..602873ea 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -64,10 +64,11 @@ export const crews = { ? "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) => + /** `running` is false for an archived crew, which stopped long ago. */ + deleteBody: (crew: string, bots: number, running: boolean) => 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.`, + : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} ${running ? "stop now and leave" : "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: { diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 9b79d765..828327b5 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -86,6 +86,16 @@ export const account: Messages["account"] = { size: "Tamaño", sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.", language: "Idioma", + archived: "Archivados", + archivedIntro: + "Los bots y los equipos que archivaste están detenidos y fuera de la vista, y Botloft todavía guarda sus conversaciones. Elimina uno para quitarlo para siempre.", + archivedEmpty: "No hay nada archivado.", + archivedLoadFailed: "No se pudo cargar lo archivado", + archivedCrew: (bots: number, when: string) => + `Equipo · ${bots === 1 ? "1 bot" : `${bots} bots`} · archivado ${when}`, + archivedBot: (crew: string, when: string) => `Bot de ${crew} · archivado ${when}`, + deleteArchived: "Eliminar", + deleteArchivedOne: (name: string) => `Eliminar ${name}`, about: "Acerca de", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index e1d45944..71199fec 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -68,8 +68,8 @@ export const bots: Messages["bots"] = { 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.`, + removed: (name: string, running: boolean) => + `${name} ${running ? "se detiene ahora y sale" : "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:`, diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 45c2a0cb..93279dcd 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -61,10 +61,10 @@ export const crews: Messages["crews"] = { ? "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) => + deleteBody: (crew: string, bots: number, running: boolean) => 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.`, + : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} ${running ? "se detienen ahora y salen" : "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: { diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index c798b426..7a82b859 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -87,6 +87,16 @@ export const account: Messages["account"] = { size: "Tamanho", sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.", language: "Idioma", + archived: "Arquivados", + archivedIntro: + "Os bots e as equipes que você arquivou estão parados e fora da vista, e o Botloft ainda guarda as conversas deles. Exclua um para tirá-lo de vez.", + archivedEmpty: "Nada está arquivado.", + archivedLoadFailed: "Não foi possível carregar o que está arquivado", + archivedCrew: (bots: number, when: string) => + `Equipe · ${bots === 1 ? "1 bot" : `${bots} bots`} · arquivada ${when}`, + archivedBot: (crew: string, when: string) => `Bot de ${crew} · arquivado ${when}`, + deleteArchived: "Excluir", + deleteArchivedOne: (name: string) => `Excluir ${name}`, about: "Sobre", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 6077acef..e1368803 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -68,8 +68,8 @@ export const bots: Messages["bots"] = { 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.`, + removed: (name: string, running: boolean) => + `${name} ${running ? "para agora e sai" : "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:`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 7cb73c13..69a115d6 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -62,10 +62,10 @@ export const crews: Messages["crews"] = { ? "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) => + deleteBody: (crew: string, bots: number, running: boolean) => 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.`, + : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} ${running ? "param agora e saem" : "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: { diff --git a/docs/spec.md b/docs/spec.md index 9c6ea0e7..89e92864 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -281,8 +281,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **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). +- **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. Os arquivados, que o resto do app não mostra, ficam em Configurações > Arquivados, cada um com o seu Excluir; a lista vem de `archive.list` (11.2). +- **Próximo passo:** na confirmação, uma caixa para mandar a pasta para a Lixeira. ## 8. Chat @@ -506,6 +506,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `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[]` | +| `archive.list` | | `{crews, bots}`: o que está arquivado, que `crews.list` e `bots.list` deixam de fora, do arquivado mais recente ao mais antigo. `bots` traz todos os bots arquivados, também os das crews arquivadas (7.6) | | `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | | `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` | | `bots.setPaused` | `botId, paused` | `Bot` | @@ -643,7 +644,7 @@ 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. 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. +- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** 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 **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. 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. - **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). From 97da749539d5ccb40c212986fefaa12f9513ecdc Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:12 -0300 Subject: [PATCH 4/9] feat(platform): move a folder to the Recycle Bin, never deleting it for good With the shell's IFileOperation on a thread of its own (it needs a single-threaded apartment). A progress sink aborts any item the shell would destroy instead of recycling, so what the bin cannot take stays where it is. The tests use the real Recycle Bin, check the folder is in it and take it out again. --- Cargo.lock | 1 + crates/botloftd/Cargo.toml | 4 +- crates/botloftd/src/platform/mod.rs | 8 +- crates/botloftd/src/platform/unix.rs | 8 + crates/botloftd/src/platform/windows.rs | 2 + .../botloftd/src/platform/windows/recycle.rs | 352 ++++++++++++++++++ 6 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 crates/botloftd/src/platform/windows/recycle.rs diff --git a/Cargo.lock b/Cargo.lock index 038c6baf..84a49b9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "windows", + "windows-core", ] [[package]] diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index ad01857b..055cd827 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -36,7 +36,9 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity", "Win32_UI_Shell", "Win32_UI_Shell_Common"] } +# Named by the code that `windows::core::implement` writes. +windows-core = "0.62.2" [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 752556ac..ebc2f2c9 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -12,13 +12,13 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, + register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; #[cfg(windows)] pub use windows::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, + register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; /// A program the system starts for the owner (spec 14). diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 1b8e49ac..fe7bd35b 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -103,6 +103,14 @@ pub fn delete_task(_name: &str) -> io::Result { Err(no_tasks()) } +/// There is no Recycle Bin off Windows: the folder stays. +pub fn recycle(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "the Recycle Bin is only supported on Windows", + )) +} + /// Unix has no Windows sign-in to tell apart. pub fn sign_in_id() -> Option { None diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index e2645193..dddad3fc 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -6,6 +6,7 @@ mod env; mod job; mod owner; mod power; +mod recycle; mod sign_in; mod task; mod task_xml; @@ -15,6 +16,7 @@ pub use env::user_environment; pub use job::ProcessJob; pub use owner::owner_name; pub use power::KeepAwake; +pub use recycle::recycle; pub use sign_in::sign_in_id; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; diff --git a/crates/botloftd/src/platform/windows/recycle.rs b/crates/botloftd/src/platform/windows/recycle.rs new file mode 100644 index 00000000..57ecce7e --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle.rs @@ -0,0 +1,352 @@ +//! Moving a folder to the Recycle Bin (spec 7.6), with the shell's +//! `IFileOperation`. +//! +//! When the bin cannot take something (too big for it, a path too long, a +//! drive without a bin) and the shell may not ask, it deletes for good. The +//! sink below refuses exactly that, so the folder stays where it is. + +use std::cell::Cell; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::rc::Rc; + +use windows::Win32::Foundation::E_ABORT; +use windows::Win32::System::Com::{ + CLSCTX_ALL, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::UI::Shell::{ + FOF_ALLOWUNDO, FOF_NO_UI, FOFX_EARLYFAILURE, FOFX_RECYCLEONDELETE, FileOperation, + IFileOperation, IFileOperationProgressSink, IFileOperationProgressSink_Impl, IShellItem, + SHCreateItemFromParsingName, TSF_DELETE_RECYCLE_IF_POSSIBLE, +}; +use windows::core::{HRESULT, PCWSTR, Ref, Result as ComResult, implement}; + +/// Moves `path` to the current user's Recycle Bin, or fails and leaves it +/// where it is: nothing is ever deleted for good. `Unsupported` means the +/// bin cannot take it; another error may pass, like a file still in use. +pub fn recycle(path: &Path) -> io::Result<()> { + let path = path.to_owned(); + // The shell's file operations need a single-threaded apartment, which + // a thread of the daemon's pool may not be: they get one of their own. + std::thread::spawn(move || on_this_thread(&path)) + .join() + .map_err(|_| io::Error::other("the move to the Recycle Bin crashed"))? +} + +fn on_this_thread(path: &Path) -> io::Result<()> { + if !path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "the folder is not there", + )); + } + // SAFETY: plain COM initialization for this thread, which is ours. + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }.ok()?; + let moved = delete_to_bin(path); + // SAFETY: pairs with the successful CoInitializeEx above; every COM + // object of `delete_to_bin` is released by now. + unsafe { CoUninitialize() }; + moved +} + +fn delete_to_bin(path: &Path) -> io::Result<()> { + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let refused = Rc::new(Cell::new(false)); + let sink: IFileOperationProgressSink = Sink { + refused: Rc::clone(&refused), + } + .into(); + // SAFETY: COM is initialized on this thread; `wide` is NUL-terminated + // and outlives the calls; the interfaces are used on this thread only. + let (performed, aborted) = unsafe { + let operation: IFileOperation = CoCreateInstance(&FileOperation, None, CLSCTX_ALL)?; + operation.SetOperationFlags( + FOF_NO_UI | FOF_ALLOWUNDO | FOFX_RECYCLEONDELETE | FOFX_EARLYFAILURE, + )?; + let item: IShellItem = SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None)?; + operation.DeleteItem(&item, &sink)?; + let performed = operation.PerformOperations(); + let aborted = operation.GetAnyOperationsAborted(); + (performed, aborted) + }; + if refused.get() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows cannot put this folder in the Recycle Bin", + )); + } + performed?; + if aborted.is_ok_and(|aborted| aborted.as_bool()) || path.exists() { + return Err(io::Error::other( + "Windows did not move the folder to the Recycle Bin", + )); + } + Ok(()) +} + +/// Stops the operation when an item would be deleted for good. +#[implement(IFileOperationProgressSink)] +struct Sink { + refused: Rc>, +} + +#[allow(non_snake_case)] +impl IFileOperationProgressSink_Impl for Sink_Impl { + fn PreDeleteItem(&self, dwflags: u32, _item: Ref) -> ComResult<()> { + // The shell sets this flag on what goes to the bin, and leaves it + // out on what it is about to destroy. + if dwflags & TSF_DELETE_RECYCLE_IF_POSSIBLE.0 as u32 == 0 { + self.refused.set(true); + return Err(E_ABORT.into()); + } + Ok(()) + } + + fn StartOperations(&self) -> ComResult<()> { + Ok(()) + } + + fn FinishOperations(&self, _result: HRESULT) -> ComResult<()> { + Ok(()) + } + + fn PreRenameItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> { + Ok(()) + } + + fn PostRenameItem( + &self, + _: u32, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreMoveItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + ) -> ComResult<()> { + Ok(()) + } + + fn PostMoveItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreCopyItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + ) -> ComResult<()> { + Ok(()) + } + + fn PostCopyItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PostDeleteItem( + &self, + _: u32, + _: Ref, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreNewItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> { + Ok(()) + } + + fn PostNewItem( + &self, + _: u32, + _: Ref, + _: &PCWSTR, + _: &PCWSTR, + _: u32, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn UpdateProgress(&self, _total: u32, _so_far: u32) -> ComResult<()> { + Ok(()) + } + + fn ResetTimer(&self) -> ComResult<()> { + Ok(()) + } + + fn PauseTimer(&self) -> ComResult<()> { + Ok(()) + } + + fn ResumeTimer(&self) -> ComResult<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::platform::windows::CurrentUser; + + /// Where the bin keeps what came from `original`: its `$I` record and + /// the `$R` item itself. `None` if the bin has no such thing. + fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { + let sid = CurrentUser::query().ok()?.sid_string().ok()?; + let drive = original.components().next()?.as_os_str().to_string_lossy(); + let bin = PathBuf::from(format!(r"{drive}\$Recycle.Bin\{sid}")); + let wanted = original.to_string_lossy().to_lowercase(); + for entry in std::fs::read_dir(bin).ok()?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(rest) = name.strip_prefix("$I") else { + continue; + }; + // Version, size, time, then the length of the path and the + // path itself in UTF-16 (the record of Windows 10 and later). + let Ok(record) = std::fs::read(entry.path()) else { + continue; + }; + let units: Vec = record + .get(28..) + .unwrap_or_default() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + if String::from_utf16_lossy(&units).to_lowercase() == wanted { + return Some(( + entry.path(), + entry.path().with_file_name(format!("$R{rest}")), + )); + } + } + None + } + + /// Takes the test's own folder out of the bin, so a test run leaves + /// nothing behind. + fn purge(record: &Path, item: &Path) { + let _ = std::fs::remove_dir_all(item); + let _ = std::fs::remove_file(record); + } + + #[test] + fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-test"); + std::fs::create_dir_all(folder.join("attachments")).expect("folders"); + std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file"); + std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file"); + + recycle(&folder).expect("recycle"); + + assert!(!folder.exists(), "the folder left its place"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + assert_eq!( + std::fs::read_to_string(item.join("CLAUDE.md")).expect("kept"), + "what the bot learned" + ); + assert!(item.join("attachments").join("plan.txt").is_file()); + purge(&record, &item); + } + + #[test] + fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-busy"); + std::fs::create_dir(&folder).expect("folder"); + let log = folder.join("session.log"); + std::fs::write(&log, "still writing").expect("file"); + // As a process that has not ended yet holds its files. + let held = std::fs::File::open(&log).expect("open"); + + let err = recycle(&folder).expect_err("a file is in use"); + assert_ne!(err.kind(), io::ErrorKind::Unsupported, "worth trying again"); + assert_eq!( + std::fs::read_to_string(&log).expect("kept"), + "still writing" + ); + + drop(held); + recycle(&folder).expect("recycle"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + purge(&record, &item); + } + + #[test] + fn a_missing_folder_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let err = recycle(&dir.path().join("nothing-here")).expect_err("nothing to move"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { + // Paths past 260 characters, as a bot's `node_modules` has. Windows + // 11 moves the folder whole; whatever a Windows does, nothing of it + // may be lost. + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-long"); + std::fs::create_dir(&folder).expect("folder"); + // The verbatim form of the path, which may pass 260 characters. + let top = std::fs::canonicalize(&folder).expect("verbatim path"); + let mut deep = top.clone(); + for _ in 0..8 { + deep.push("a-folder-name-that-is-forty-characters--"); + } + std::fs::create_dir_all(&deep).expect("deep folders"); + let file = deep.join("notes.txt"); + std::fs::write(&file, "deep").expect("deep file"); + + match recycle(&folder) { + // Moved whole: it must be in the bin, with the deep file. + Ok(()) => { + assert!(!folder.exists()); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + let mut kept = std::fs::canonicalize(&item).expect("verbatim path"); + kept.extend(deep.strip_prefix(&top).expect("under the folder")); + let kept = std::fs::read_to_string(kept.join("notes.txt")).expect("deep file"); + assert_eq!(kept, "deep"); + purge(&record, &item); + } + // Refused: nothing was touched. + Err(_) => { + assert_eq!(std::fs::read_to_string(&file).expect("kept"), "deep"); + } + } + } +} From f7ec55856d6f628260635dd9c7dab14c60b0e25c Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:12 -0300 Subject: [PATCH 5/9] feat(daemon): send a deleted bot's or crew's folder to the Recycle Bin when asked bots.delete and crews.delete take recycleFolder. The method answers at once; the folder moves afterwards, off the server's workers, tried again while the process that was just killed still holds its files, and folder.recycled tells the apps how it ended. Only a folder under the workspaces goes, and never one that holds a work folder the owner chose. --- app/src/lib/protocol.gen.ts | 39 ++++- crates/botloft-core/src/protocol/export.rs | 13 +- crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-core/src/protocol/model.rs | 38 +++++ crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/main.rs | 2 + crates/botloftd/src/rpc/mod.rs | 3 + crates/botloftd/src/service/delete.rs | 58 ++++++- crates/botloftd/src/state.rs | 14 +- crates/botloftd/src/trash.rs | 136 ++++++++++++++++ crates/botloftd/src/workspace/folder.rs | 2 +- crates/botloftd/tests/common/mod.rs | 8 + crates/botloftd/tests/delete_folders.rs | 172 +++++++++++++++++++++ docs/spec.md | 15 +- 14 files changed, 482 insertions(+), 20 deletions(-) create mode 100644 crates/botloftd/src/trash.rs create mode 100644 crates/botloftd/tests/delete_folders.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index b7c079cd..118cad7b 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -379,6 +379,40 @@ export type CrewDeleted = { crewId: CrewId, }; */ export type Archive = { crews: Array, bots: Array, }; +/** + * Params of `bots.delete` (spec 7.6). + */ +export type BotsDeleteParams = { botId: BotId, +/** + * Also move the bot's folder to the Recycle Bin; it stays when absent. + */ +recycleFolder?: boolean, }; + +/** + * Params of `crews.delete` (spec 7.6). + */ +export type CrewsDeleteParams = { crewId: CrewId, +/** + * Also move the crew's own folder, with each bot's folder and + * `shared`, to the Recycle Bin; it stays when absent. A work folder + * the owner chose is never moved. + */ +recycleFolder?: boolean, }; + +/** + * Params of the `folder.recycled` notification: how the move of a deleted + * bot's or crew's folder to the Recycle Bin ended (spec 7.6). + */ +export type FolderRecycled = { +/** + * Absolute path the folder had. + */ +path: string, +/** + * Why the folder is still there; `null` when it is in the bin. + */ +error: string | null, }; + /** * Who wrote a message. */ @@ -1011,7 +1045,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 }; + "crews.delete": { params: CrewsDeleteParams; result: CrewDeleted }; "archive.list": { params: undefined; result: Archive }; "bots.list": { params: BotsListParams; result: Array }; "bots.create": { params: BotsCreateParams; result: Bot }; @@ -1020,7 +1054,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.delete": { params: BotsDeleteParams; result: BotDeleted }; "bots.restart": { params: BotsRestartParams; result: Bot }; "chat.history": { params: ChatHistoryParams; result: Array }; "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; @@ -1054,6 +1088,7 @@ export interface RpcNotifications { "bot.changed": Bot; "crew.deleted": CrewDeleted; "bot.deleted": BotDeleted; + "folder.recycled": FolderRecycled; "bot.state": BotStateChanged; "chat.item": ChatItemChanged; "chat.delta": ChatDelta; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 482bc3e2..05ce8536 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -128,6 +128,9 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -230,7 +233,7 @@ fn export_bindings() { let bot_deleted = out.name::(); out.method( method::CREWS_DELETE, - &out.name::(), + &out.name::(), &crew_deleted, ); out.method(method::ARCHIVE_LIST, "undefined", &out.name::()); @@ -255,7 +258,7 @@ fn export_bindings() { out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); out.method( method::BOTS_DELETE, - &out.name::(), + &out.name::(), &bot_deleted, ); out.method(method::BOTS_RESTART, &out.name::(), &bot); @@ -330,6 +333,12 @@ fn export_bindings() { " \"{}\": {bot_deleted};", notification::BOT_DELETED ); + let _ = writeln!( + out.text, + " \"{}\": {};", + notification::FOLDER_RECYCLED, + out.name::() + ); 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 228f2afe..0ed482e3 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -134,6 +134,7 @@ pub mod notification { pub const CREW_DELETED: &str = "crew.deleted"; pub const BOT_CHANGED: &str = "bot.changed"; pub const BOT_DELETED: &str = "bot.deleted"; + pub const FOLDER_RECYCLED: &str = "folder.recycled"; 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 e6d0810a..f29684d7 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -190,3 +190,41 @@ pub struct Archive { pub crews: Vec, pub bots: Vec, } + +/// Params of `bots.delete` (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsDeleteParams { + pub bot_id: BotId, + /// Also move the bot's folder to the Recycle Bin; it stays when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub recycle_folder: Option, +} + +/// Params of `crews.delete` (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsDeleteParams { + pub crew_id: CrewId, + /// Also move the crew's own folder, with each bot's folder and + /// `shared`, to the Recycle Bin; it stays when absent. A work folder + /// the owner chose is never moved. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub recycle_folder: Option, +} + +/// Params of the `folder.recycled` notification: how the move of a deleted +/// bot's or crew's folder to the Recycle Bin ended (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct FolderRecycled { + /// Absolute path the folder had. + pub path: String, + /// Why the folder is still there; `null` when it is in the bin. + pub error: Option, +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 20e3ac95..8a6dbb30 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -24,4 +24,5 @@ pub mod settings; pub mod state; pub mod supervisor; pub mod tools; +pub mod trash; pub mod workspace; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index b7c14979..b840d96f 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -18,6 +18,7 @@ use botloftd::service::tasks::TaskSettings; use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; +use botloftd::trash::RecycleBin; use botloftd::{approvals, autostart, keep_awake, logging, routines, secrets, server}; use clap::{Parser, Subcommand}; use tokio::net::TcpListener; @@ -129,6 +130,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a bots: BotSettings::from_config(&config), browser: BrowserSettings::from_config(&config), settings: LiveSettings::new(Some(config_path), &config), + trash: Arc::new(RecycleBin), }); // No bot process survived the last run, so nobody waits for these. approvals::expire_all(&daemon); diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index 8b935c2b..ce3b07a7 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -202,6 +202,9 @@ fn to_notification(event: &Event) -> String { 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::FolderRecycled(folder) => { + (notification::FOLDER_RECYCLED, serde_json::to_value(folder)) + } 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/service/delete.rs b/crates/botloftd/src/service/delete.rs index 1efdad1e..a62a54ac 100644 --- a/crates/botloftd/src/service/delete.rs +++ b/crates/botloftd/src/service/delete.rs @@ -1,10 +1,15 @@ //! `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). +//! database; deleting keeps only the folders on disk (spec 5), or sends +//! them to the Recycle Bin when the owner asks. use botloft_core::ids::{BotId, MessageId}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use botloft_core::protocol::{ - BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind, + BotDeleted, BotsDeleteParams, Crew, CrewDeleted, CrewsDeleteParams, FolderRecycled, Message, + MessageKind, SenderKind, }; use botloft_store::{BotRecord, Store, TaskFilter}; use tracing::{info, warn}; @@ -13,15 +18,18 @@ use super::messages::post; use super::tasks::UNFINISHED; use super::{ApiResult, bots, crews, lead}; use crate::state::{Daemon, Event}; +use crate::{trash, workspace}; /// 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 { +/// routines and tasks leave the database. Its folder stays, unless the +/// owner asked for it to go to the Recycle Bin. +pub fn bot(daemon: &Daemon, params: BotsDeleteParams) -> 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)?; + let folder = daemon.paths.bot_workspace(&crew.slug, &record.slug); store.delete_bot(&record.id)?; stop(daemon, &record.id); @@ -41,12 +49,16 @@ pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { daemon.approvals.forget(&waiting); release(daemon, &deleted.bot_id); info!(bot = %deleted.bot_id, "bot deleted"); + if params.recycle_folder == Some(true) { + recycle(daemon, &crew, folder); + } Ok(deleted) } /// Deletes the crew with every bot in it, archived ones too. Its folders -/// stay. -pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { +/// stay, unless the owner asked for the crew's own folder to go to the +/// Recycle Bin. +pub fn crew(daemon: &Daemon, params: CrewsDeleteParams) -> ApiResult { let store = daemon.store(); let crew = crews::find(&store, ¶ms.crew_id)?; let bots = store.bots(Some(&crew.id), true)?; @@ -58,7 +70,9 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { for bot in &bots { stop(daemon, &bot.id); } - let deleted = CrewDeleted { crew_id: crew.id }; + let deleted = CrewDeleted { + crew_id: crew.id.clone(), + }; daemon.emit(Event::CrewDeleted(deleted.clone())); drop(store); daemon.approvals.forget(&waiting); @@ -66,9 +80,39 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { release(daemon, &bot.id); } info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted"); + if params.recycle_folder == Some(true) { + recycle(daemon, &crew, daemon.paths.crew_dir(&crew.slug)); + } Ok(deleted) } +/// Sends `folder`, which Botloft made for a deleted bot or crew, to the +/// Recycle Bin, and tells the apps how it went (spec 7.6). A work folder +/// the owner chose is theirs: a folder that holds it stays. +fn recycle(daemon: &Daemon, crew: &Crew, folder: PathBuf) { + let chosen = crew + .work_folder_chosen + .then(|| Path::new(&crew.work_folder)); + // Only ever a folder under the workspaces, never the root of them. + let refused = if !workspace::folder::contains(&daemon.paths.workspaces_root, &folder) + || workspace::folder::contains(&folder, &daemon.paths.workspaces_root) + { + Some("it is not one of Botloft's folders") + } else if chosen.is_some_and(|chosen| workspace::folder::contains(&folder, chosen)) { + Some("the work folder you chose for the crew is inside it") + } else { + None + }; + if let Some(reason) = refused { + daemon.emit(Event::FolderRecycled(FolderRecycled { + path: folder.to_string_lossy().into_owned(), + error: Some(reason.to_owned()), + })); + return; + } + trash::move_away(Arc::clone(&daemon.trash), daemon.events(), folder); +} + /// Kills the bot's process and closes its browser, profile included. fn stop(daemon: &Daemon, bot: &BotId) { daemon.supervisor.forget(bot); diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 73742cfc..019369db 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -6,7 +6,8 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, - ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, Routine, RoutineRun, + ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -24,6 +25,7 @@ use crate::secrets::TokenHash; use crate::service::tasks::TaskSettings; use crate::settings::LiveSettings; use crate::supervisor::{Supervisor, SupervisorSettings}; +use crate::trash::Trash; use crate::workspace::WorkspaceEnv; /// Something that changed and every connected app should hear about. @@ -33,6 +35,7 @@ pub enum Event { CrewDeleted(CrewDeleted), BotChanged(Bot), BotDeleted(BotDeleted), + FolderRecycled(FolderRecycled), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), @@ -83,6 +86,8 @@ pub struct DaemonOptions { pub browser: BrowserSettings, /// What the owner changes in the app's Settings. pub settings: LiveSettings, + /// Where a deleted bot's folder goes when the owner asks (spec 7.6). + pub trash: Arc, } pub struct Daemon { @@ -97,6 +102,7 @@ pub struct Daemon { pub routines: Routines, pub browsers: Browsers, pub screens: Screens, + pub trash: Arc, /// Time for everything stored or compared with stored times. pub clock: Arc, store: Mutex, @@ -132,6 +138,7 @@ impl Daemon { Arc::clone(&options.clock), ), screens: Screens::default(), + trash: options.trash, clock: options.clock, paths: options.paths, port: options.port, @@ -169,6 +176,11 @@ impl Daemon { self.events.subscribe() } + /// A sender for work that outlives the call that started it. + pub(crate) fn events(&self) -> broadcast::Sender { + self.events.clone() + } + pub(crate) fn emit(&self, event: Event) { // No receivers just means no app is connected. let _ = self.events.send(event); diff --git a/crates/botloftd/src/trash.rs b/crates/botloftd/src/trash.rs new file mode 100644 index 00000000..93585d6f --- /dev/null +++ b/crates/botloftd/src/trash.rs @@ -0,0 +1,136 @@ +//! Where the folder of a deleted bot or crew goes when the owner asks +//! (spec 7.6): the Recycle Bin, behind a trait so tests move no folder +//! into a real one. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use botloft_core::protocol::FolderRecycled; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::platform; +use crate::state::Event; + +/// How many times a folder that cannot move yet is tried, and how long +/// between tries: a process that was just killed still holds its files. +const TRIES: u32 = 20; +const RETRY_EVERY: Duration = Duration::from_millis(500); + +pub trait Trash: Send + Sync { + /// Moves the folder away, where the owner can get it back, or fails + /// and leaves it as it is. `Unsupported` means it can never go there. + fn put(&self, folder: &Path) -> io::Result<()>; +} + +/// The Windows Recycle Bin. +pub struct RecycleBin; + +impl Trash for RecycleBin { + fn put(&self, folder: &Path) -> io::Result<()> { + platform::recycle(folder) + } +} + +/// Moves `folder` to the trash off the async workers, trying again while +/// something still holds it, and tells the apps how it ended +/// (`folder.recycled`). +pub(crate) fn move_away(trash: Arc, events: broadcast::Sender, folder: PathBuf) { + let work = move || { + let error = put_with_retries(trash.as_ref(), &folder) + .err() + .map(|err| err.to_string()); + match &error { + None => info!("a deleted bot's folder went to the Recycle Bin"), + Some(error) => warn!("a folder did not go to the Recycle Bin: {error}"), + } + // No receivers just means no app is connected. + let _ = events.send(Event::FolderRecycled(FolderRecycled { + path: folder.to_string_lossy().into_owned(), + error, + })); + }; + match tokio::runtime::Handle::try_current() { + Ok(runtime) => drop(runtime.spawn_blocking(work)), + Err(_) => work(), + } +} + +fn put_with_retries(trash: &dyn Trash, folder: &Path) -> io::Result<()> { + let mut tries = 0; + loop { + tries += 1; + match trash.put(folder) { + Ok(()) => return Ok(()), + // Already gone: there is nothing left to move. + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported || tries >= TRIES => { + return Err(err); + } + Err(_) => std::thread::sleep(RETRY_EVERY), + } + } +} + +/// A trash for tests: folders move into `bin`, and the next tries can be +/// made to fail. +#[derive(Clone)] +pub struct FakeTrash { + bin: PathBuf, + state: Arc>, +} + +#[derive(Default)] +struct FakeState { + moved: Vec, + /// Errors the next tries answer with, first to last. + failures: Vec, + tries: usize, +} + +impl FakeTrash { + pub fn new(bin: PathBuf) -> Self { + Self { + bin, + state: Arc::default(), + } + } + + /// The folders that were moved, in order, as they were named. + pub fn moved(&self) -> Vec { + self.lock().moved.clone() + } + + /// How many times a move was tried. + pub fn tries(&self) -> usize { + self.lock().tries + } + + /// The next tries fail with these, in order, before one may work. + pub fn fail_next(&self, failures: &[io::ErrorKind]) { + self.lock().failures = failures.to_vec(); + } + + fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl Trash for FakeTrash { + fn put(&self, folder: &Path) -> io::Result<()> { + let mut state = self.lock(); + state.tries += 1; + if !state.failures.is_empty() { + return Err(io::Error::new(state.failures.remove(0), "fake failure")); + } + if !folder.exists() { + return Err(io::Error::new(io::ErrorKind::NotFound, "no such folder")); + } + std::fs::create_dir_all(&self.bin)?; + std::fs::rename(folder, self.bin.join(state.moved.len().to_string()))?; + state.moved.push(folder.to_owned()); + Ok(()) + } +} diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs index 6271246f..03dffbbe 100644 --- a/crates/botloftd/src/workspace/folder.rs +++ b/crates/botloftd/src/workspace/folder.rs @@ -62,7 +62,7 @@ fn parts(path: &Path) -> Vec { } /// Whether `outer` is `inner` or one of its parents. -fn contains(outer: &Path, inner: &Path) -> bool { +pub(crate) fn contains(outer: &Path, inner: &Path) -> bool { let (outer, inner) = (parts(outer), parts(inner)); inner.len() >= outer.len() && inner[..outer.len()] == outer[..] } diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index 8d14da67..d998df26 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -31,6 +31,7 @@ use botloftd::service::tasks::TaskSettings; use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; +use botloftd::trash::FakeTrash; use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -48,6 +49,8 @@ pub struct TestDaemon { pub daemon: Arc, pub runtime: FakeRuntime, pub clock: Arc, + /// Where folders go instead of the Recycle Bin. + pub trash: FakeTrash, _dir: TempDir, } @@ -89,6 +92,7 @@ pub struct Parts { pub runtime: FakeRuntime, pub clock: Arc, pub paths: Paths, + pub trash: FakeTrash, pub dir: TempDir, } @@ -97,6 +101,7 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces")); let runtime = FakeRuntime::new(); let clock = Arc::new(ManualClock::new()); + let trash = FakeTrash::new(dir.path().join("bin")); let daemon = Daemon::new(DaemonOptions { paths: paths.clone(), port: 45710, @@ -115,12 +120,14 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { // Approvals time out fast enough for a test to wait for it. settings: LiveSettings::new(None, &Config::default()) .with_approval_wait(Duration::from_secs(3)), + trash: Arc::new(trash.clone()), }); Parts { daemon, runtime, clock, paths, + trash, dir, } } @@ -156,6 +163,7 @@ impl TestDaemon { daemon: parts.daemon, runtime: parts.runtime, clock: parts.clock, + trash: parts.trash, _dir: parts.dir, } } diff --git a/crates/botloftd/tests/delete_folders.rs b/crates/botloftd/tests/delete_folders.rs new file mode 100644 index 00000000..6837e88b --- /dev/null +++ b/crates/botloftd/tests/delete_folders.rs @@ -0,0 +1,172 @@ +//! Deleting a bot or a crew and sending its folder to the Recycle Bin +//! (spec 7.6). The test daemon's trash is a fake: folders move into a +//! folder of the test, never into a real bin. + +mod common; + +use std::io::ErrorKind; +use std::path::Path; + +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +async fn crew_with_scout(app: &mut Client, work_folder: Option<&Path>) -> (Value, Value) { + let mut crew = json!({ "name": "Ops" }); + if let Some(folder) = work_folder { + crew["workFolder"] = json!(folder.to_string_lossy()); + } + let crew = app.call("crews.create", crew).await.expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let scout = app.call("bots.create", bot).await.expect("bot"); + (crew, scout) +} + +fn text(path: &Path) -> Value { + json!(path.to_string_lossy()) +} + +#[tokio::test] +async fn a_deleted_bots_folder_goes_to_the_bin_when_asked() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + let workspace = t.paths.bot_workspace("ops", "scout"); + std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory"); + + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!( + moved, + json!({ "path": text(&workspace), "error": Value::Null }) + ); + assert!(!workspace.exists()); + assert_eq!(t.trash.moved(), [workspace]); + // Only the bot's own folder went. + assert!(t.paths.shared_dir("ops").is_dir()); +} + +#[tokio::test] +async fn without_asking_the_folder_stays_and_nothing_is_said() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (crew, scout) = crew_with_scout(&mut app, None).await; + for params in [ + json!({ "botId": scout["id"] }), + json!({ "botId": scout["id"], "recycleFolder": false }), + ] { + // The second call finds no bot: the first one deleted it. + let _ = app.call("bots.delete", params).await; + } + app.call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect("delete the crew"); + assert_eq!(t.trash.tries(), 0); + assert!(t.paths.bot_workspace("ops", "scout").is_dir()); + assert!(t.paths.shared_dir("ops").is_dir()); +} + +#[tokio::test] +async fn a_folder_still_in_use_is_tried_again() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + t.trash.fail_next(&[ErrorKind::PermissionDenied]); + + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!(moved["error"], Value::Null); + assert_eq!(t.trash.tries(), 2); + assert!(!t.paths.bot_workspace("ops", "scout").exists()); +} + +#[tokio::test] +async fn a_folder_the_bin_cannot_take_stays_and_the_app_is_told() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + t.trash.fail_next(&[ErrorKind::Unsupported]); + let workspace = t.paths.bot_workspace("ops", "scout"); + + // The bot is deleted all the same. + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let kept = app.notification("folder.recycled").await; + assert_eq!(kept["path"], text(&workspace)); + assert_eq!(kept["error"], "fake failure"); + assert_eq!(t.trash.tries(), 1, "no use trying again"); + assert!(workspace.is_dir()); + assert_eq!( + app.call("bots.list", json!({})).await.expect("bots"), + json!([]) + ); +} + +#[tokio::test] +async fn a_deleted_crews_own_folder_goes_whole() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + // The owner's own folder, outside Botloft's: it is never moved. + let project = t.paths.workspaces_root.with_file_name("project"); + let (crew, scout) = crew_with_scout(&mut app, Some(&project)).await; + app.call("bots.archive", json!({ "botId": scout["id"] })) + .await + .expect("archive"); + std::fs::write(project.join("site.html"), "the owner's work").expect("work"); + let folder = t.paths.crew_dir("ops"); + + app.call( + "crews.delete", + json!({ "crewId": crew["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!( + moved, + json!({ "path": text(&folder), "error": Value::Null }) + ); + assert!(!folder.exists(), "bots' folders and shared went with it"); + assert_eq!( + std::fs::read_to_string(project.join("site.html")).expect("kept"), + "the owner's work" + ); +} + +#[tokio::test] +async fn a_folder_that_holds_the_owners_work_folder_stays() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + // The owner chose a work folder inside the crew's own folder. + let inside = t.paths.crew_dir("ops").join("project"); + let (crew, _) = crew_with_scout(&mut app, Some(&inside)).await; + let folder = t.paths.crew_dir("ops"); + + app.call( + "crews.delete", + json!({ "crewId": crew["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let kept = app.notification("folder.recycled").await; + assert_eq!(kept["path"], text(&folder)); + assert_eq!( + kept["error"], + "the work folder you chose for the crew is inside it" + ); + assert_eq!(t.trash.tries(), 0); + assert!(inside.is_dir()); +} diff --git a/docs/spec.md b/docs/spec.md index 89e92864..63f71de5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,7 +117,7 @@ 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. +**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e, a menos que o dono peça a Lixeira na confirmação (abaixo), 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. Se o dono marca a caixa da confirmação, a pasta que o Botloft criou vai para a Lixeira do Windows, de onde ainda dá para recuperar: o workspace do bot, ou a pasta inteira da crew (`\\`, com a `shared\` e os workspaces). A pasta de trabalho que o dono escolheu nunca é movida. 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. @@ -282,7 +282,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **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. Os arquivados, que o resto do app não mostra, ficam em Configurações > Arquivados, cada um com o seu Excluir; a lista vem de `archive.list` (11.2). -- **Próximo passo:** na confirmação, uma caixa para mandar a pasta para a Lixeira. +- **Lixeira, se o dono pedir:** `recycleFolder: true` em `bots.delete` manda o workspace do bot para a Lixeira do Windows; em `crews.delete`, a pasta da crew inteira (`\\`: a `shared\` e o workspace de cada bot, também dos arquivados e dos excluídos antes). Só vai uma pasta que esteja dentro de `workspaces_root`, nunca a própria raiz, e nunca uma que contenha a pasta de trabalho escolhida pelo dono (5): nesse caso nada é movido. O método responde na hora, como sem a opção; a pasta vai depois, fora dos workers do servidor, porque o processo recém-morto ainda segura arquivos por um instante: até 20 tentativas, uma a cada 500 ms. Quando termina, o daemon avisa com `folder.recycled {path, error}`: `error: null` se a pasta está na Lixeira (ou já não existia), senão o motivo de ela ter ficado. Nada é apagado de vez: o que a Lixeira não pode receber fica onde está, sem nova tentativa (14). ## 8. Chat @@ -504,7 +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) | +| `crews.delete` | `crewId, recycleFolder?` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela; com `recycleFolder`, a pasta dela vai depois para a Lixeira (7.6) | | `bots.list` | `crewId?` | `Bot[]` | | `archive.list` | | `{crews, bots}`: o que está arquivado, que `crews.list` e `bots.list` deixam de fora, do arquivado mais recente ao mais antigo. `bots` traz todos os bots arquivados, também os das crews arquivadas (7.6) | | `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | @@ -514,7 +514,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) | +| `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (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` | @@ -534,7 +534,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`, `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). +`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (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 @@ -595,7 +595,8 @@ 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 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 | +| 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. A pasta que vai para a Lixeira (abaixo) é tentada de novo por 10 s enquanto o processo morto ainda a segura | +| Lixeira (testado no Windows 11 25H2, 2026-09-30) | `IFileOperation` do shell, numa thread própria em STA (o shell exige, e as threads do daemon são MTA), com `FOF_NO_UI`, `FOF_ALLOWUNDO`, `FOFX_RECYCLEONDELETE` e `FOFX_EARLYFAILURE`. Sem poder perguntar, o shell apaga de vez o que a Lixeira não pode receber; por isso um `IFileOperationProgressSink` aborta em `PreDeleteItem` todo item que chega sem `TSF_DELETE_RECYCLE_IF_POSSIBLE`, e a pasta fica. Visto: a pasta vai inteira e aparece no registro `$I` da Lixeira com o caminho original; uma pasta com caminhos de mais de 260 caracteres também vai inteira, com o arquivo do fundo; com um arquivo aberto por outro processo a operação falha, a pasta fica como estava e vai na tentativa seguinte. Os testes tiram da Lixeira o que puseram | | 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) | @@ -653,7 +654,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". +- **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. No mesmo quadro, uma caixa desmarcada: "Mandar esta pasta para a Lixeira" (na crew, "Mandar as pastas da equipe para a Lixeira"). Marcada, o texto do quadro passa a dizer que a pasta vai para a Lixeira, de onde ainda dá para recuperar, e a crew mostra a pasta dela no Botloft em vez da `shared\`; se o dono escolheu a pasta de trabalho, o quadro diz que a pasta de cada bot vai e a escolhida fica onde está. O app manda `recycleFolder` só com a caixa marcada. Se a pasta não pôde ir (`folder.recycled` com `error`), um aviso diz que ela não foi para a Lixeira e continua lá, com o caminho e o motivo. 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. From 617da0fe60b0948d67c780f721b6ae8ebf875ac8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:14 -0300 Subject: [PATCH 6/9] feat(app): a box in the delete confirmation to send the folder to the Recycle Bin Unchecked by default: the folder stays. Checked, the confirmation says the folder goes to the Recycle Bin, where it can still be got back, and a notice tells the owner if it could not go. --- app/src/features/bots/DeleteBot.tsx | 16 +++++- app/src/features/bots/delete.test.tsx | 79 +++++++++++++++++++++++++++ app/src/features/crews/DeleteCrew.tsx | 27 ++++++++- app/src/i18n/en/bots.ts | 3 + app/src/i18n/en/crews.ts | 5 ++ app/src/i18n/en/shell.ts | 3 + app/src/i18n/es/bots.ts | 3 + app/src/i18n/es/crews.ts | 5 ++ app/src/i18n/es/shell.ts | 1 + app/src/i18n/pt-BR/bots.ts | 3 + app/src/i18n/pt-BR/crews.ts | 5 ++ app/src/i18n/pt-BR/shell.ts | 1 + app/src/lib/fake.ts | 13 +++++ app/src/lib/fakeBots.ts | 5 +- app/src/lib/fakeCrews.ts | 7 ++- app/src/shell/Workspace.tsx | 2 + app/src/shell/folders.ts | 21 +++++++ app/src/ui/KeptFolder.tsx | 25 ++++++++- 18 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 app/src/shell/folders.ts diff --git a/app/src/features/bots/DeleteBot.tsx b/app/src/features/bots/DeleteBot.tsx index 90310386..9b2a9dfb 100644 --- a/app/src/features/bots/DeleteBot.tsx +++ b/app/src/features/bots/DeleteBot.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -7,7 +8,8 @@ import { attempt } from "../../ui/toast"; /** * Asks before deleting a bot for good (spec 7.6), saying what goes and - * what stays. `chiefOf` is the crew it leads, if it leads one. + * what stays, with the choice to send its folder to the Recycle Bin. + * `chiefOf` is the crew it leads, if it leads one. */ export function DeleteBot({ bot, @@ -23,6 +25,8 @@ export function DeleteBot({ const words = useT().bots.header; const api = useApi(); const dropBot = useApp((state) => state.dropBot); + // The folder stays unless the owner says otherwise. + const [recycle, setRecycle] = useState(false); return ( attempt(words.failed.delete, async () => { - dropBot((await api.call("bots.delete", { botId: bot.id })).botId); + const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id }; + dropBot((await api.call("bots.delete", asked)).botId); onDeleted?.(); }) } @@ -39,7 +44,12 @@ export function DeleteBot({ {words.deleteConfirm.removed(bot.name, bot.archivedAt === null)} {chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`}

    - {words.deleteConfirm.kept(bot.name)} + + {recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)} +
    ); } diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx index a3ba782c..ff07d360 100644 --- a/app/src/features/bots/delete.test.tsx +++ b/app/src/features/bots/delete.test.tsx @@ -161,3 +161,82 @@ describe("deleting a crew", () => { expect(fake.crews.size).toBe(0); }); }); + +describe("sending the folder to the Recycle Bin", () => { + const box = (dialog: HTMLElement, name: string) => + within(dialog).getByRole("checkbox", { name }) as HTMLInputElement; + + test("is off until the owner asks, then the bot's folder goes with it", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + const recycle = box(dialog, "Move this folder to the Recycle Bin"); + expect(recycle.checked).toBe(false); + + fireEvent.click(recycle); + expect(dialog.textContent).toContain( + "Scout's folder goes to the Recycle Bin, where you can still get it back:", + ); + expect(dialog.textContent).not.toContain("stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.calls.at(-1)).toEqual({ + method: "bots.delete", + params: { botId: scout.id, recycleFolder: true }, + }); + expect(fake.recycled).toEqual([scout.workspace]); + }); + + test("says so when the folder could not go, and where it still is", async () => { + const { fake, scout } = await twoBots(); + fake.recycleError = "Windows cannot put this folder in the Recycle Bin"; + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(box(dialog, "Move this folder to the Recycle Bin")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + + const notice = await screen.findByText(/did not go to the Recycle Bin/); + expect(notice.textContent).toBe( + `The folder ${scout.workspace} did not go to the Recycle Bin and is still there: Windows cannot put this folder in the Recycle Bin`, + ); + // The bot is deleted all the same. + expect(fake.bots.has(scout.id)).toBe(false); + expect(fake.recycled).toEqual([]); + }); + + test("a crew's own folder goes whole, and a work folder the owner chose never does", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const site = await fake.call("crews.create", { + name: "Site", + workFolder: "C:\\Projects\\Site", + }); + fake.addBot(ops.id, "Scout"); + renderApp(fake); + await crewOpened("Ops"); + + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const dialog = screen.getByRole("dialog", { name: "Delete Ops?" }); + fireEvent.click(box(dialog, "Move the crew's folders to the Recycle Bin")); + expect(dialog.textContent).toContain("The crew's folder goes to the Recycle Bin"); + // The crew's folder itself, not only `shared` inside it. + expect(within(dialog).getByText("C:\\Users\\owner\\Botloft\\ops")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"])); + expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true }); + + fireEvent.click(within(sidebar()).getByRole("button", { name: /Site/ })); + await crewOpened("Site"); + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const chosen = screen.getByRole("dialog", { name: "Delete Site?" }); + fireEvent.click(box(chosen, "Move the crew's folders to the Recycle Bin")); + expect(chosen.textContent).toContain("The work folder you chose stays where it is:"); + expect(within(chosen).getByText(site.workFolder)).toBeDefined(); + }); +}); diff --git a/app/src/features/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx index ef786118..1c6466f6 100644 --- a/app/src/features/crews/DeleteCrew.tsx +++ b/app/src/features/crews/DeleteCrew.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useT } from "../../i18n"; import type { Crew } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -7,7 +8,8 @@ import { attempt } from "../../ui/toast"; /** * Asks before deleting a crew for good (spec 7.6), saying what goes and - * what stays. `bots` is how many bots go with it. + * what stays, with the choice to send its folders to the Recycle Bin. + * `bots` is how many bots go with it. */ export function DeleteCrew({ crew, @@ -23,6 +25,19 @@ export function DeleteCrew({ const words = useT().crews.view; const api = useApi(); const dropCrew = useApp((state) => state.dropCrew); + // The folders stay unless the owner says otherwise. + const [recycle, setRecycle] = useState(false); + // What goes to the bin is the crew's own folder, where `shared` is + // (spec 5); a work folder the owner chose is somewhere else and stays. + const shown = + recycle && !crew.workFolderChosen + ? crew.workFolder.replace(/[\\/]shared$/, "") + : crew.workFolder; + const said = !recycle + ? words.deleteKept + : crew.workFolderChosen + ? words.deleteRecycledChosen + : words.deleteRecycled; return ( attempt(words.failed.delete, async () => { - dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId); + const asked = recycle ? { crewId: crew.id, recycleFolder: true } : { crewId: crew.id }; + dropCrew((await api.call("crews.delete", asked)).crewId); onDeleted?.(); }) } >

    {words.deleteBody(crew.name, bots, crew.archivedAt === null)}

    - {words.deleteKept} + + {said} +
    ); } diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index f7fa547a..a8234f5e 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -77,6 +77,9 @@ export const bots = { `${name} ${running ? "stops now and leaves" : "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:`, + recycle: "Move this folder to the Recycle Bin", + recycled: (name: string) => + `${name}'s folder goes to the Recycle Bin, where you can still get it back:`, }, }, dialog: { diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 602873ea..b84f232e 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -71,6 +71,11 @@ export const crews = { : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} ${running ? "stop now and leave" : "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:", + deleteRecycle: "Move the crew's folders to the Recycle Bin", + deleteRecycled: + "The crew's folder goes to the Recycle Bin, with each bot's folder and the work folder in it. You can still get it back from there:", + deleteRecycledChosen: + "Each bot's own folder goes to the Recycle Bin, where you can still get it back. The work folder you chose stays where it is:", failed: { pause: "Could not pause the crew", resume: "Could not resume the crew", diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index 745c3686..7be45d3c 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -22,6 +22,9 @@ export const shell = { reconnecting: "Reconnecting…", }, loadFailed: "Could not load your crews", + /** A deleted bot's or crew's folder that could not go to the Recycle Bin. */ + recycleFailed: (path: string) => + `The folder ${path} did not go to the Recycle Bin and is still there`, pickCrew: "Pick a crew on the left, or create one with the + button.", botsCantStart: "Bots can't start", signIn: { diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index 71199fec..0cb7480a 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -73,6 +73,9 @@ export const bots: Messages["bots"] = { chief: (crew: string) => `${crew} se queda sin jefe.`, kept: (name: string) => `La carpeta de ${name} sigue en tu ordenador, con todo lo que contiene:`, + recycle: "Enviar esta carpeta a la Papelera", + recycled: (name: string) => + `La carpeta de ${name} va a la Papelera, de donde todavía puedes recuperarla:`, }, }, dialog: { diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 93279dcd..06d239c4 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -67,6 +67,11 @@ export const crews: Messages["crews"] = { : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} ${running ? "se detienen ahora y salen" : "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:", + deleteRecycle: "Enviar las carpetas del equipo a la Papelera", + deleteRecycled: + "La carpeta del equipo va a la Papelera, con la carpeta de cada bot y la carpeta de trabajo dentro. Todavía puedes recuperarla de allí:", + deleteRecycledChosen: + "La carpeta de cada bot va a la Papelera, de donde todavía puedes recuperarla. La carpeta de trabajo que elegiste se queda donde está:", failed: { pause: "No se pudo pausar el equipo", resume: "No se pudo reanudar el equipo", diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index 8020d799..e4f239d3 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -20,6 +20,7 @@ export const shell: Messages["shell"] = { reconnecting: "Reconectando…", }, loadFailed: "No se pudieron cargar tus equipos", + recycleFailed: (path: string) => `La carpeta ${path} no fue a la Papelera y sigue allí`, pickCrew: "Elige un equipo a la izquierda, o crea uno con el botón +.", botsCantStart: "Los bots no pueden iniciar", signIn: { diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index e1368803..8469dc91 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -73,6 +73,9 @@ export const bots: Messages["bots"] = { chief: (crew: string) => `${crew} fica sem chefe.`, kept: (name: string) => `A pasta de ${name} continua no seu computador, com tudo o que está nela:`, + recycle: "Mandar esta pasta para a Lixeira", + recycled: (name: string) => + `A pasta de ${name} vai para a Lixeira, de onde ainda dá para recuperar:`, }, }, dialog: { diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 69a115d6..36ea056a 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -68,6 +68,11 @@ export const crews: Messages["crews"] = { : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} ${running ? "param agora e saem" : "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:", + deleteRecycle: "Mandar as pastas da equipe para a Lixeira", + deleteRecycled: + "A pasta da equipe vai para a Lixeira, com a pasta de cada bot e a pasta de trabalho dentro. Ainda dá para recuperar de lá:", + deleteRecycledChosen: + "A pasta de cada bot vai para a Lixeira, de onde ainda dá para recuperar. A pasta de trabalho que você escolheu fica onde está:", failed: { pause: "Não foi possível pausar a equipe", resume: "Não foi possível retomar a equipe", diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index bb82a25a..e95ed305 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -20,6 +20,7 @@ export const shell: Messages["shell"] = { reconnecting: "Reconectando…", }, loadFailed: "Não foi possível carregar suas equipes", + recycleFailed: (path: string) => `A pasta ${path} não foi para a Lixeira e continua lá`, pickCrew: "Escolha uma equipe à esquerda, ou crie uma com o botão +.", botsCantStart: "Os bots não conseguem iniciar", signIn: { diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 43685160..6d350ba2 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -58,6 +58,10 @@ export class FakeBotloft implements BotloftApi { /** How many times the app asked for a new Claude Code check. */ refreshes = 0; settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 }; + /** Folders the owner sent to the Recycle Bin with a delete, in order. */ + readonly recycled: string[] = []; + /** Why the next folder cannot go to the bin, if it cannot. */ + recycleError: string | null = null; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -183,6 +187,15 @@ export class FakeBotloft implements BotloftApi { ); } + /** The Recycle Bin takes `path`, or the app hears why it did not. */ + recycle(path: string): void { + const error = this.recycleError; + if (error === null) { + this.recycled.push(path); + } + this.emit({ name: "folder.recycled", params: { path, error } }); + } + changedCrew(crew: Crew): Crew { this.emit({ name: "crew.changed", params: crew }); return crew; diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 6057818a..449811d7 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -91,7 +91,7 @@ export function botHandlers(fake: FakeBotloft): Pick { } return bot; }, - "bots.delete": ({ botId }) => { + "bots.delete": ({ botId, recycleFolder }) => { const bot = fake.bot(botId, false); purgeBot(fake, bot); // A deleted chief leaves its crew without one. @@ -102,6 +102,9 @@ export function botHandlers(fake: FakeBotloft): Pick { } const deleted = { botId, crewId: bot.crewId }; fake.emit({ name: "bot.deleted", params: deleted }); + if (recycleFolder) { + fake.recycle(bot.workspace); + } return deleted; }, }; diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts index 549817cb..d71539f4 100644 --- a/app/src/lib/fakeCrews.ts +++ b/app/src/lib/fakeCrews.ts @@ -82,8 +82,8 @@ export function crewHandlers(fake: FakeBotloft): Pick { } return crew; }, - "crews.delete": ({ crewId }) => { - fake.crew(crewId, false); + "crews.delete": ({ crewId, recycleFolder }) => { + const crew = fake.crew(crewId, false); for (const bot of [...fake.bots.values()]) { if (bot.crewId === crewId) { purgeBot(fake, bot); @@ -91,6 +91,9 @@ export function crewHandlers(fake: FakeBotloft): Pick { } fake.crews.delete(crewId); fake.emit({ name: "crew.deleted", params: { crewId } }); + if (recycleFolder) { + fake.recycle(`C:\\Users\\owner\\Botloft\\${crew.slug}`); + } return { crewId }; }, }; diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 80447e4c..1dcf878d 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -17,6 +17,7 @@ import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; import { useBotAlerts } from "./alerts"; import { useAttentionMark } from "./attention"; +import { useFolderNotices } from "./folders"; import { useOpenAtSignIn } from "./signIn"; import { TitleBar } from "./TitleBar"; import { useTray } from "./tray"; @@ -31,6 +32,7 @@ export function Workspace() { useAttentionMark(); useTray(); useBotAlerts(); + useFolderNotices(); useOpenAtSignIn(); let main: ReactNode; diff --git a/app/src/shell/folders.ts b/app/src/shell/folders.ts new file mode 100644 index 00000000..28bc214c --- /dev/null +++ b/app/src/shell/folders.ts @@ -0,0 +1,21 @@ +// Telling the owner when the folder of a deleted bot or crew could not go +// to the Recycle Bin (spec 7.6): the daemon moves it after answering, so +// the news comes as a notification. + +import { useEffect } from "react"; +import { t } from "../i18n"; +import { useApi } from "../store/context"; +import { notifyError } from "../ui/toast"; + +export function useFolderNotices(): void { + const api = useApi(); + useEffect( + () => + api.subscribe((event) => { + if (event.name === "folder.recycled" && event.params.error !== null) { + notifyError(t().shell.recycleFailed(event.params.path), event.params.error); + } + }), + [api], + ); +} diff --git a/app/src/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx index 3f07db07..e6ed6ecc 100644 --- a/app/src/ui/KeptFolder.tsx +++ b/app/src/ui/KeptFolder.tsx @@ -1,13 +1,34 @@ 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 }) { +/** + * In a confirmation: what happens to a folder on disk, and where it is. + * `recycle` adds the choice to send it to the Recycle Bin. + */ +export function KeptFolder({ + path, + children, + recycle, +}: { + path: string; + children: ReactNode; + recycle?: { label: string; checked: boolean; onChange(checked: boolean): void }; +}) { return (

    {children}

    {path}

    + {recycle && ( + + )}
    ); } From 61490b5b38808fcba2a15f84fdeb000cd704a571 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:38:41 -0300 Subject: [PATCH 7/9] test(platform): the Recycle Bin tests in a file of their own --- .../botloftd/src/platform/windows/recycle.rs | 136 +----------------- .../src/platform/windows/recycle/tests.rs | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 135 deletions(-) create mode 100644 crates/botloftd/src/platform/windows/recycle/tests.rs diff --git a/crates/botloftd/src/platform/windows/recycle.rs b/crates/botloftd/src/platform/windows/recycle.rs index 57ecce7e..3027404c 100644 --- a/crates/botloftd/src/platform/windows/recycle.rs +++ b/crates/botloftd/src/platform/windows/recycle.rs @@ -215,138 +215,4 @@ impl IFileOperationProgressSink_Impl for Sink_Impl { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::platform::windows::CurrentUser; - - /// Where the bin keeps what came from `original`: its `$I` record and - /// the `$R` item itself. `None` if the bin has no such thing. - fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { - let sid = CurrentUser::query().ok()?.sid_string().ok()?; - let drive = original.components().next()?.as_os_str().to_string_lossy(); - let bin = PathBuf::from(format!(r"{drive}\$Recycle.Bin\{sid}")); - let wanted = original.to_string_lossy().to_lowercase(); - for entry in std::fs::read_dir(bin).ok()?.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - let Some(rest) = name.strip_prefix("$I") else { - continue; - }; - // Version, size, time, then the length of the path and the - // path itself in UTF-16 (the record of Windows 10 and later). - let Ok(record) = std::fs::read(entry.path()) else { - continue; - }; - let units: Vec = record - .get(28..) - .unwrap_or_default() - .as_chunks::<2>() - .0 - .iter() - .map(|pair| u16::from_le_bytes(*pair)) - .take_while(|unit| *unit != 0) - .collect(); - if String::from_utf16_lossy(&units).to_lowercase() == wanted { - return Some(( - entry.path(), - entry.path().with_file_name(format!("$R{rest}")), - )); - } - } - None - } - - /// Takes the test's own folder out of the bin, so a test run leaves - /// nothing behind. - fn purge(record: &Path, item: &Path) { - let _ = std::fs::remove_dir_all(item); - let _ = std::fs::remove_file(record); - } - - #[test] - fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { - let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-test"); - std::fs::create_dir_all(folder.join("attachments")).expect("folders"); - std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file"); - std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file"); - - recycle(&folder).expect("recycle"); - - assert!(!folder.exists(), "the folder left its place"); - let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); - assert_eq!( - std::fs::read_to_string(item.join("CLAUDE.md")).expect("kept"), - "what the bot learned" - ); - assert!(item.join("attachments").join("plan.txt").is_file()); - purge(&record, &item); - } - - #[test] - fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { - let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-busy"); - std::fs::create_dir(&folder).expect("folder"); - let log = folder.join("session.log"); - std::fs::write(&log, "still writing").expect("file"); - // As a process that has not ended yet holds its files. - let held = std::fs::File::open(&log).expect("open"); - - let err = recycle(&folder).expect_err("a file is in use"); - assert_ne!(err.kind(), io::ErrorKind::Unsupported, "worth trying again"); - assert_eq!( - std::fs::read_to_string(&log).expect("kept"), - "still writing" - ); - - drop(held); - recycle(&folder).expect("recycle"); - let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); - purge(&record, &item); - } - - #[test] - fn a_missing_folder_is_an_error() { - let dir = tempfile::tempdir().expect("tempdir"); - let err = recycle(&dir.path().join("nothing-here")).expect_err("nothing to move"); - assert_eq!(err.kind(), io::ErrorKind::NotFound); - } - - #[test] - fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { - // Paths past 260 characters, as a bot's `node_modules` has. Windows - // 11 moves the folder whole; whatever a Windows does, nothing of it - // may be lost. - let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-long"); - std::fs::create_dir(&folder).expect("folder"); - // The verbatim form of the path, which may pass 260 characters. - let top = std::fs::canonicalize(&folder).expect("verbatim path"); - let mut deep = top.clone(); - for _ in 0..8 { - deep.push("a-folder-name-that-is-forty-characters--"); - } - std::fs::create_dir_all(&deep).expect("deep folders"); - let file = deep.join("notes.txt"); - std::fs::write(&file, "deep").expect("deep file"); - - match recycle(&folder) { - // Moved whole: it must be in the bin, with the deep file. - Ok(()) => { - assert!(!folder.exists()); - let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); - let mut kept = std::fs::canonicalize(&item).expect("verbatim path"); - kept.extend(deep.strip_prefix(&top).expect("under the folder")); - let kept = std::fs::read_to_string(kept.join("notes.txt")).expect("deep file"); - assert_eq!(kept, "deep"); - purge(&record, &item); - } - // Refused: nothing was touched. - Err(_) => { - assert_eq!(std::fs::read_to_string(&file).expect("kept"), "deep"); - } - } - } -} +mod tests; diff --git a/crates/botloftd/src/platform/windows/recycle/tests.rs b/crates/botloftd/src/platform/windows/recycle/tests.rs new file mode 100644 index 00000000..b2b1ec63 --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle/tests.rs @@ -0,0 +1,136 @@ +//! The real Recycle Bin: each test checks its folder is in it, then takes +//! it out again, so a run leaves nothing behind. + +use std::path::PathBuf; + +use super::*; +use crate::platform::windows::CurrentUser; + +/// Where the bin keeps what came from `original`: its `$I` record and +/// the `$R` item itself. `None` if the bin has no such thing. +fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { + let sid = CurrentUser::query().ok()?.sid_string().ok()?; + let drive = original.components().next()?.as_os_str().to_string_lossy(); + let bin = PathBuf::from(format!(r"{drive}\$Recycle.Bin\{sid}")); + let wanted = original.to_string_lossy().to_lowercase(); + for entry in std::fs::read_dir(bin).ok()?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(rest) = name.strip_prefix("$I") else { + continue; + }; + // Version, size, time, then the length of the path and the + // path itself in UTF-16 (the record of Windows 10 and later). + let Ok(record) = std::fs::read(entry.path()) else { + continue; + }; + let units: Vec = record + .get(28..) + .unwrap_or_default() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + if String::from_utf16_lossy(&units).to_lowercase() == wanted { + return Some(( + entry.path(), + entry.path().with_file_name(format!("$R{rest}")), + )); + } + } + None +} + +/// Takes the test's own folder out of the bin, so a test run leaves +/// nothing behind. +fn purge(record: &Path, item: &Path) { + let _ = std::fs::remove_dir_all(item); + let _ = std::fs::remove_file(record); +} + +#[test] +fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-test"); + std::fs::create_dir_all(folder.join("attachments")).expect("folders"); + std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file"); + std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file"); + + recycle(&folder).expect("recycle"); + + assert!(!folder.exists(), "the folder left its place"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + assert_eq!( + std::fs::read_to_string(item.join("CLAUDE.md")).expect("kept"), + "what the bot learned" + ); + assert!(item.join("attachments").join("plan.txt").is_file()); + purge(&record, &item); +} + +#[test] +fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-busy"); + std::fs::create_dir(&folder).expect("folder"); + let log = folder.join("session.log"); + std::fs::write(&log, "still writing").expect("file"); + // As a process that has not ended yet holds its files. + let held = std::fs::File::open(&log).expect("open"); + + let err = recycle(&folder).expect_err("a file is in use"); + assert_ne!(err.kind(), io::ErrorKind::Unsupported, "worth trying again"); + assert_eq!( + std::fs::read_to_string(&log).expect("kept"), + "still writing" + ); + + drop(held); + recycle(&folder).expect("recycle"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + purge(&record, &item); +} + +#[test] +fn a_missing_folder_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let err = recycle(&dir.path().join("nothing-here")).expect_err("nothing to move"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); +} + +#[test] +fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { + // Paths past 260 characters, as a bot's `node_modules` has. Windows + // 11 moves the folder whole; whatever a Windows does, nothing of it + // may be lost. + let dir = tempfile::tempdir().expect("tempdir"); + let folder = dir.path().join("botloft-recycle-long"); + std::fs::create_dir(&folder).expect("folder"); + // The verbatim form of the path, which may pass 260 characters. + let top = std::fs::canonicalize(&folder).expect("verbatim path"); + let mut deep = top.clone(); + for _ in 0..8 { + deep.push("a-folder-name-that-is-forty-characters--"); + } + std::fs::create_dir_all(&deep).expect("deep folders"); + let file = deep.join("notes.txt"); + std::fs::write(&file, "deep").expect("deep file"); + + match recycle(&folder) { + // Moved whole: it must be in the bin, with the deep file. + Ok(()) => { + assert!(!folder.exists()); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + let mut kept = std::fs::canonicalize(&item).expect("verbatim path"); + kept.extend(deep.strip_prefix(&top).expect("under the folder")); + let kept = std::fs::read_to_string(kept.join("notes.txt")).expect("deep file"); + assert_eq!(kept, "deep"); + purge(&record, &item); + } + // Refused: nothing was touched. + Err(_) => { + assert_eq!(std::fs::read_to_string(&file).expect("kept"), "deep"); + } + } +} From 6d013257ecc2190f6daaba58df16cbd0690e5727 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:41:06 -0300 Subject: [PATCH 8/9] docs(spec): why the Recycle Bin move has a thread of its own --- docs/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec.md b/docs/spec.md index 63f71de5..49a7a2b4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -596,7 +596,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | 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 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. A pasta que vai para a Lixeira (abaixo) é tentada de novo por 10 s enquanto o processo morto ainda a segura | -| Lixeira (testado no Windows 11 25H2, 2026-09-30) | `IFileOperation` do shell, numa thread própria em STA (o shell exige, e as threads do daemon são MTA), com `FOF_NO_UI`, `FOF_ALLOWUNDO`, `FOFX_RECYCLEONDELETE` e `FOFX_EARLYFAILURE`. Sem poder perguntar, o shell apaga de vez o que a Lixeira não pode receber; por isso um `IFileOperationProgressSink` aborta em `PreDeleteItem` todo item que chega sem `TSF_DELETE_RECYCLE_IF_POSSIBLE`, e a pasta fica. Visto: a pasta vai inteira e aparece no registro `$I` da Lixeira com o caminho original; uma pasta com caminhos de mais de 260 caracteres também vai inteira, com o arquivo do fundo; com um arquivo aberto por outro processo a operação falha, a pasta fica como estava e vai na tentativa seguinte. Os testes tiram da Lixeira o que puseram | +| Lixeira (testado no Windows 11 25H2, 2026-09-30) | `IFileOperation` do shell, numa thread própria em STA (o shell exige, e uma thread do daemon pode já estar em MTA, como a do Agendador), com `FOF_NO_UI`, `FOF_ALLOWUNDO`, `FOFX_RECYCLEONDELETE` e `FOFX_EARLYFAILURE`. Sem poder perguntar, o shell apaga de vez o que a Lixeira não pode receber; por isso um `IFileOperationProgressSink` aborta em `PreDeleteItem` todo item que chega sem `TSF_DELETE_RECYCLE_IF_POSSIBLE`, e a pasta fica. Visto: a pasta vai inteira e aparece no registro `$I` da Lixeira com o caminho original; uma pasta com caminhos de mais de 260 caracteres também vai inteira, com o arquivo do fundo; com um arquivo aberto por outro processo a operação falha, a pasta fica como estava e vai na tentativa seguinte. Os testes tiram da Lixeira o que puseram | | 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) | From 0f20b9f955058c03c96d136732470998a1d5f9e8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:56:01 -0300 Subject: [PATCH 9/9] test(platform): look in the Recycle Bin for the long form of the path The bin records the long path, and the temporary folder of the CI runner comes in the short one (RUNNER~1), so the tests did not find the folder they had just moved. --- .../src/platform/windows/recycle/tests.rs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/botloftd/src/platform/windows/recycle/tests.rs b/crates/botloftd/src/platform/windows/recycle/tests.rs index b2b1ec63..3e794e29 100644 --- a/crates/botloftd/src/platform/windows/recycle/tests.rs +++ b/crates/botloftd/src/platform/windows/recycle/tests.rs @@ -6,6 +6,10 @@ use std::path::PathBuf; use super::*; use crate::platform::windows::CurrentUser; +/// What `canonicalize` puts before a path, which may then pass 260 +/// characters. +const VERBATIM: &str = r"\\?\"; + /// Where the bin keeps what came from `original`: its `$I` record and /// the `$R` item itself. `None` if the bin has no such thing. fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { @@ -42,6 +46,15 @@ fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { None } +/// Where a test's folder `name` goes, under `dir`, in its long form: the +/// bin records the long path, and the temporary folder may be given in +/// the short one (`RUNNER~1`). +fn place(dir: &tempfile::TempDir, name: &str) -> PathBuf { + let long = std::fs::canonicalize(dir.path()).expect("long path"); + let plain = long.to_string_lossy(); + PathBuf::from(plain.strip_prefix(VERBATIM).unwrap_or(&plain)).join(name) +} + /// Takes the test's own folder out of the bin, so a test run leaves /// nothing behind. fn purge(record: &Path, item: &Path) { @@ -52,7 +65,7 @@ fn purge(record: &Path, item: &Path) { #[test] fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-test"); + let folder = place(&dir, "botloft-recycle-test"); std::fs::create_dir_all(folder.join("attachments")).expect("folders"); std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file"); std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file"); @@ -72,7 +85,7 @@ fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { #[test] fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-busy"); + let folder = place(&dir, "botloft-recycle-busy"); std::fs::create_dir(&folder).expect("folder"); let log = folder.join("session.log"); std::fs::write(&log, "still writing").expect("file"); @@ -105,10 +118,9 @@ fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { // 11 moves the folder whole; whatever a Windows does, nothing of it // may be lost. let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-long"); + let folder = place(&dir, "botloft-recycle-long"); std::fs::create_dir(&folder).expect("folder"); - // The verbatim form of the path, which may pass 260 characters. - let top = std::fs::canonicalize(&folder).expect("verbatim path"); + let top = PathBuf::from(format!("{VERBATIM}{}", folder.display())); let mut deep = top.clone(); for _ in 0..8 { deep.push("a-folder-name-that-is-forty-characters--");