From 827cfe46771915b35b1a9f4f21580b86944d0fd7 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:40:12 -0300 Subject: [PATCH 1/6] feat(store): delete a bot or a crew with everything that points at it Archiving keeps the rows. Deleting removes the bot with its chat, approvals, the messages it received, its routines, browser sites and tasks, in one transaction; what it sent to other bots stays without a sender. --- crates/botloft-store/src/approvals.rs | 13 + crates/botloft-store/src/delete.rs | 77 ++++++ crates/botloft-store/src/delete/tests.rs | 304 +++++++++++++++++++++++ crates/botloft-store/src/lib.rs | 1 + 4 files changed, 395 insertions(+) create mode 100644 crates/botloft-store/src/delete.rs create mode 100644 crates/botloft-store/src/delete/tests.rs diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs index 72574322..8a5c1568 100644 --- a/crates/botloft-store/src/approvals.rs +++ b/crates/botloft-store/src/approvals.rs @@ -95,6 +95,15 @@ impl Store { .optional()?) } + /// The bot's requests that still wait for the owner. + pub fn pending_approvals(&self, bot: &BotId) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT id FROM approvals WHERE bot_id = ?1 AND status = 'pending'")?; + let rows = stmt.query_map([bot.as_str()], |row| parse_column(row, 0))?; + Ok(rows.collect::>()?) + } + /// Expires every pending approval, of one bot or of all: their process /// is gone, so nobody waits for the answer. pub fn expire_approvals(&self, bot: Option<&BotId>, now: i64) -> Result> { @@ -204,6 +213,10 @@ mod tests { let fx = Fixture::new(); let first = pending(&fx, 0); let other = pending(&fx, 1); + assert_eq!( + fx.store.pending_approvals(&fx.bots[0].id).expect("pending"), + vec![first.approval.id.clone()] + ); let expired = fx .store .expire_approvals(Some(&fx.bots[0].id), 40) diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs new file mode 100644 index 00000000..1d586340 --- /dev/null +++ b/crates/botloft-store/src/delete.rs @@ -0,0 +1,77 @@ +//! Deleting a bot or a crew for good (spec 7.6 and 12). Archiving keeps the +//! rows; this removes them with everything that points at them, in one +//! transaction. + +use botloft_core::ids::{BotId, CrewId}; +use rusqlite::Connection; + +use crate::{Result, Store}; + +/// Every statement takes the bot's id. The order follows the foreign keys: +/// what points at a row goes, or lets go of it, before the row. +const DELETE_BOT: &[&str] = &[ + "DELETE FROM approvals WHERE bot_id = ?1", + "DELETE FROM chat_items WHERE bot_id = ?1", + "DELETE FROM browser_sites WHERE bot_id = ?1", + "DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + // What the bot received goes with it. + "UPDATE routine_runs SET message_id = NULL \ + WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM attachments WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM deliveries WHERE bot_id = ?1 \ + OR message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM messages WHERE to_bot_id = ?1", + "UPDATE messages SET routine_id = NULL \ + WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + "DELETE FROM routines WHERE bot_id = ?1", + // What it sent stays with who received it, without a sender. + "UPDATE messages SET from_bot_id = NULL WHERE from_bot_id = ?1", + "UPDATE messages SET task_id = NULL WHERE task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "UPDATE tasks SET origin_task_id = NULL WHERE origin_task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "DELETE FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1", + "UPDATE crews SET lead_bot_id = NULL WHERE lead_bot_id = ?1", +]; + +fn delete_bot_in(conn: &Connection, bot: &str) -> Result { + for statement in DELETE_BOT { + conn.execute(statement, [bot])?; + } + Ok(conn.execute("DELETE FROM bots WHERE id = ?1", [bot])? > 0) +} + +impl Store { + /// Deletes the bot with its chat, its approvals, the messages it + /// received, its routines, its browser sites and the tasks it asked for + /// or was given. Messages it sent to other bots stay, without a sender. + /// `false`, with nothing changed, if there is no such bot. + pub fn delete_bot(&self, id: &BotId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let deleted = delete_bot_in(&tx, id.as_str())?; + tx.commit()?; + Ok(deleted) + } + + /// Deletes the crew and every bot in it, archived ones too, as + /// [`Store::delete_bot`] does. `false` if there is no such crew. + pub fn delete_crew(&self, id: &CrewId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let bots: Vec = { + let mut stmt = tx.prepare("SELECT id FROM bots WHERE crew_id = ?1")?; + let rows = stmt.query_map([id.as_str()], |row| row.get(0))?; + rows.collect::>()? + }; + for bot in &bots { + delete_bot_in(&tx, bot)?; + } + // Every message and task of a crew is to or for one of its bots, so + // nothing of the crew is left to hold it. + let deleted = tx.execute("DELETE FROM crews WHERE id = ?1", [id.as_str()])? > 0; + tx.commit()?; + Ok(deleted) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs new file mode 100644 index 00000000..8a4e53c4 --- /dev/null +++ b/crates/botloft-store/src/delete/tests.rs @@ -0,0 +1,304 @@ +//! Tests for deleting bots and crews. + +use botloft_core::ids::{ApprovalId, AttachmentId, ChatItemId, RoutineId, RoutineRunId, TaskId}; +use botloft_core::protocol::{ + Approval, ApprovalItem, ApprovalStatus, Attachment, ChatBody, ChatItem, Message, MessageKind, + Missed, Overlap, Routine, RoutineRun, RunStatus, Schedule, SenderKind, Task, TaskStatus, +}; + +use super::*; +use crate::tests::{Fixture, message_to}; +use crate::{ApprovalRecord, MessageFilter, TaskFilter}; + +/// A message from bot `from` to bot `to` of the fixture. +fn from_bot(fx: &Fixture, from: usize, to: usize, body: &str) -> Message { + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, body); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + fx.store + .insert_message(&message, &delivery, None) + .expect("message"); + message +} + +/// Bot `from` asks bot `to` for a task. +fn ask(fx: &Fixture, from: usize, to: usize, origin: Option<&Task>) -> Task { + let task = Task { + id: TaskId::generate(), + crew_id: fx.crew.id.clone(), + requester_bot_id: fx.bots[from].id.clone(), + assignee_bot_id: fx.bots[to].id.clone(), + status: TaskStatus::Open, + deadline_at: 1_000, + hops: 1, + origin_task_id: origin.map(|task| task.id.clone()), + result: None, + created_at: 0, + updated_at: 0, + }; + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, "do it"); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + message.kind = MessageKind::Task; + message.task_id = Some(task.id.clone()); + fx.store + .insert_message(&message, &delivery, Some(&task)) + .expect("task"); + task +} + +/// Everything a bot can own, for bot 0 (scout): a note from the owner with +/// a file, an approval, a browser site and a routine that ran once. +struct Owned { + attachment: AttachmentId, + approval: ApprovalId, + routine: RoutineId, +} + +fn fill(fx: &Fixture) -> Owned { + let scout = &fx.bots[0].id; + let (mut note, delivery) = message_to(&fx.crew.id, scout, "see attached"); + let attachment = AttachmentId::generate(); + note.attachments = vec![Attachment { + id: attachment.clone(), + name: "plan.pdf".into(), + media_type: "application/pdf".into(), + size: 2048, + path: "attachments/2026-09-30/plan.pdf".into(), + }]; + fx.store + .insert_message(¬e, &delivery, None) + .expect("note"); + + let approval = Approval { + id: ApprovalId::generate(), + bot_id: scout.clone(), + tool_name: "Bash".into(), + summary: "ls".into(), + input: r#"{"command":"ls"}"#.into(), + status: ApprovalStatus::Pending, + note: None, + created_at: 10, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: scout.clone(), + body: ChatBody::Approval(ApprovalItem { + approval_id: approval.id.clone(), + tool_name: approval.tool_name.clone(), + summary: approval.summary.clone(), + input: approval.input.clone(), + status: ApprovalStatus::Pending, + note: None, + }), + created_at: 10, + updated_at: 10, + }; + fx.store.insert_chat_item(&item).expect("item"); + let record = ApprovalRecord { + approval, + chat_item_id: item.id, + tool_use_id: "toolu_1".into(), + }; + fx.store.insert_approval(&record).expect("approval"); + fx.store + .allow_browser_site(scout, "example.com", 10) + .expect("site"); + + let routine = Routine { + id: RoutineId::generate(), + bot_id: scout.clone(), + name: "Morning".into(), + prompt: "Summarize".into(), + schedule: Schedule::Interval { minutes: 60 }, + timezone: "UTC".into(), + overlap: Overlap::Skip, + missed: Missed::RunOnce, + enabled: true, + next_run_at: Some(100), + last_run: None, + created_at: 0, + updated_at: 0, + archived_at: None, + }; + fx.store.insert_routine(&routine).expect("routine"); + let (mut fired, delivery) = message_to(&fx.crew.id, scout, "Summarize"); + fired.from_kind = SenderKind::System; + fired.routine_id = Some(routine.id.clone()); + let run = RoutineRun { + id: RoutineRunId::generate(), + routine_id: routine.id.clone(), + scheduled_for: 100, + status: RunStatus::Queued, + reason: None, + skipped_count: 0, + message_id: Some(fired.id.clone()), + created_at: 100, + finished_at: None, + }; + fx.store.fire_run(&run, &fired, &delivery).expect("run"); + Owned { + attachment, + approval: record.approval.id, + routine: routine.id, + } +} + +fn count(store: &Store, table: &str) -> i64 { + store + .conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count") +} + +/// Rows that point at something that is gone. +fn dangling(store: &Store) -> usize { + let mut stmt = store + .conn + .prepare("PRAGMA foreign_key_check") + .expect("check"); + stmt.query_map([], |_| Ok(())).expect("rows").count() +} + +#[test] +fn a_deleted_bot_takes_what_was_its_own() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let owned = fill(&fx); + let mut crew = fx.crew.clone(); + crew.lead_bot_id = Some(scout.clone()); + fx.store.update_crew(&crew).expect("chief"); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + assert_eq!(fx.store.bot(&scout).expect("bot"), None); + assert!(fx.store.bot(&writer).expect("bot").is_some()); + assert!( + fx.store + .chat_history(&scout, None, 50) + .expect("chat") + .is_empty() + ); + assert!( + fx.store + .deliveries(None, Some(&scout)) + .expect("deliveries") + .is_empty() + ); + assert_eq!(fx.store.attachment(&owned.attachment).expect("file"), None); + assert_eq!(fx.store.approval(&owned.approval).expect("approval"), None); + assert_eq!(fx.store.routine(&owned.routine).expect("routine"), None); + assert!( + fx.store + .runs(&owned.routine, None, 10) + .expect("runs") + .is_empty() + ); + assert!(fx.store.browser_sites(&scout).expect("sites").is_empty()); + let crew = fx.store.crew(&fx.crew.id).expect("crew").expect("kept"); + assert_eq!(crew.lead_bot_id, None); + assert_eq!(dangling(&fx.store), 0); + + assert!(!fx.store.delete_bot(&scout).expect("again"), "already gone"); +} + +#[test] +fn what_a_deleted_bot_sent_stays_without_a_sender() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let note = from_bot(&fx, 0, 1, "the draft is in shared/"); + let reply = from_bot(&fx, 1, 0, "thanks"); + let asked = ask(&fx, 0, 1, None); + let given = ask(&fx, 1, 0, None); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + let kept = fx.store.message(¬e.id).expect("read").expect("kept"); + assert_eq!(kept.from_kind, SenderKind::Bot); + assert_eq!(kept.from_bot_id, None); + assert_eq!(fx.store.message(&reply.id).expect("read"), None); + // The writer's chat still shows what it received. + let chat = fx.store.chat_history(&writer, None, 50).expect("chat"); + assert_eq!(chat.len(), 2); + + // Tasks to and from the deleted bot go; the request the writer got + // stays as a message, without its task. + assert_eq!(fx.store.task(&asked.id).expect("task"), None); + assert_eq!(fx.store.task(&given.id).expect("task"), None); + let request = fx + .store + .messages(MessageFilter { + bot: Some(&writer), + limit: 10, + ..MessageFilter::default() + }) + .expect("messages"); + assert_eq!(request.len(), 2); + assert!(request.iter().all(|message| message.task_id.is_none())); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_chain_of_tasks_loses_only_the_deleted_link() { + let mut fx = Fixture::new(); + let mut editor = fx.bots[1].clone(); + editor.id = botloft_core::ids::BotId::generate(); + editor.handle = "editor".into(); + editor.slug = "editor".into(); + fx.store.insert_bot(&editor).expect("third bot"); + fx.bots.push(editor); + + // Scout asks the writer, who asks the editor. + let first = ask(&fx, 0, 1, None); + let second = ask(&fx, 1, 2, Some(&first)); + + assert!(fx.store.delete_bot(&fx.bots[0].id).expect("delete")); + + let left = fx.store.tasks(TaskFilter::default()).expect("tasks"); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, second.id); + assert_eq!(left[0].origin_task_id, None); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() { + let fx = Fixture::new(); + fill(&fx); + from_bot(&fx, 0, 1, "hello"); + ask(&fx, 1, 0, None); + // An archived bot of the crew goes too. + let mut archived = fx.bots[1].clone(); + archived.archived_at = Some(5); + fx.store.update_bot(&archived).expect("archive"); + + let other = Fixture::new().crew; + let other = botloft_core::protocol::Crew { + slug: "other".into(), + ..other + }; + fx.store.insert_crew(&other).expect("other crew"); + + assert!(fx.store.delete_crew(&fx.crew.id).expect("delete")); + + assert_eq!(fx.store.crews(true).expect("crews"), vec![other]); + for table in [ + "bots", + "messages", + "deliveries", + "attachments", + "chat_items", + "approvals", + "tasks", + "routines", + "routine_runs", + "browser_sites", + ] { + assert_eq!(count(&fx.store, table), 0, "{table}"); + } + assert_eq!(dangling(&fx.store), 0); + assert!(!fx.store.delete_crew(&fx.crew.id).expect("again")); +} diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index ff856452..65238c4b 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -9,6 +9,7 @@ mod bots; mod browser_sites; mod chat; mod crews; +mod delete; mod deliveries; mod messages; mod migrate; From d152015c6077a929c8bf9c949d426e01ae3d3144 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:40:12 -0300 Subject: [PATCH 2/6] feat(protocol): bots.delete and crews.delete The methods, the bot.deleted and crew.deleted notifications, the fake daemon and the app store, which drops a deleted bot with its routines, tasks and deliveries. --- app/src/lib/fakeBots.ts | 17 +++++- app/src/lib/fakeCrews.ts | 14 ++++- app/src/lib/fakeDelete.ts | 49 +++++++++++++++++ app/src/lib/protocol.gen.ts | 16 ++++++ app/src/store/app.test.ts | 62 ++++++++++++++++++++++ app/src/store/app.ts | 48 +++++++++++++++++ crates/botloft-core/src/protocol/export.rs | 24 +++++++++ crates/botloft-core/src/protocol/mod.rs | 4 ++ crates/botloft-core/src/protocol/model.rs | 19 +++++++ 9 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 app/src/lib/fakeDelete.ts diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 914fa21b..6057818a 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -1,7 +1,9 @@ // The fake daemon's `bots.*` methods, following the daemon's rules for -// handles, colors, archiving and the notifications each change sends. +// handles, colors, archiving, deleting and the notifications each change +// sends. import type { FakeBotloft, Handlers } from "./fake"; +import { purgeBot } from "./fakeDelete"; import { checkName } from "./fakeRules"; import { AVATAR_PALETTE, type Bot } from "./protocol.gen"; @@ -89,5 +91,18 @@ export function botHandlers(fake: FakeBotloft): Pick { } return bot; }, + "bots.delete": ({ botId }) => { + const bot = fake.bot(botId, false); + purgeBot(fake, bot); + // A deleted chief leaves its crew without one. + const crew = fake.crews.get(bot.crewId); + if (crew?.leadBotId === botId) { + crew.leadBotId = null; + fake.changedCrew(crew); + } + const deleted = { botId, crewId: bot.crewId }; + fake.emit({ name: "bot.deleted", params: deleted }); + return deleted; + }, }; } diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts index 72706174..549817cb 100644 --- a/app/src/lib/fakeCrews.ts +++ b/app/src/lib/fakeCrews.ts @@ -1,8 +1,9 @@ // The fake daemon's `crews.*` methods: names, slugs, the work folder, -// pausing and archiving, with the notifications each change sends. +// pausing, archiving and deleting, with the notifications each change sends. import type { FakeBotloft, Handlers } from "./fake"; import { botHandlers } from "./fakeBots"; +import { purgeBot } from "./fakeDelete"; import { checkName, invalid, slugify } from "./fakeRules"; import type { Crew } from "./protocol.gen"; @@ -81,5 +82,16 @@ export function crewHandlers(fake: FakeBotloft): Pick { } return crew; }, + "crews.delete": ({ crewId }) => { + fake.crew(crewId, false); + for (const bot of [...fake.bots.values()]) { + if (bot.crewId === crewId) { + purgeBot(fake, bot); + } + } + fake.crews.delete(crewId); + fake.emit({ name: "crew.deleted", params: { crewId } }); + return { crewId }; + }, }; } diff --git a/app/src/lib/fakeDelete.ts b/app/src/lib/fakeDelete.ts new file mode 100644 index 00000000..8be4c035 --- /dev/null +++ b/app/src/lib/fakeDelete.ts @@ -0,0 +1,49 @@ +// What the fake daemon forgets when a bot is deleted for good (spec 7.6): +// its chat, its routines, its tasks and what it received. What it sent to +// other bots stays, without a sender. + +import type { FakeBotloft } from "./fake"; +import type { Bot } from "./protocol.gen"; + +/** Drops every item that fails `keep`, in place. */ +function retain(list: T[], keep: (item: T) => boolean): void { + list.splice(0, list.length, ...list.filter(keep)); +} + +/** Removes the bot and everything that was its own. Sends no notification. */ +export function purgeBot(fake: FakeBotloft, bot: Bot): void { + const { chat, conversation, routines } = fake; + fake.bots.delete(bot.id); + retain(chat.items, (item) => item.botId !== bot.id); + for (const [id, approval] of chat.approvals) { + if (approval.botId === bot.id) { + chat.approvals.delete(id); + } + } + for (const [id, routine] of routines.routines) { + if (routine.botId === bot.id) { + routines.routines.delete(id); + } + } + for (const [id, delivery] of conversation.deliveries) { + if (delivery.botId === bot.id) { + conversation.deliveries.delete(id); + } + } + const gone = new Set(); + for (const [id, task] of conversation.tasks) { + if (task.requesterBotId === bot.id || task.assigneeBotId === bot.id) { + conversation.tasks.delete(id); + gone.add(id); + } + } + retain(conversation.messages, (message) => message.toBotId !== bot.id); + for (const message of conversation.messages) { + if (message.fromBotId === bot.id) { + message.fromBotId = null; + } + if (message.taskId !== null && gone.has(message.taskId)) { + message.taskId = null; + } + } +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1aa00419..5b35c1d4 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -360,6 +360,18 @@ fresh?: boolean, }; */ export type BotStateChanged = { botId: BotId, state: BotState, generation: number | null, }; +/** + * A bot that was deleted for good (spec 7.6): the result of `bots.delete` + * and the params of the `bot.deleted` notification. + */ +export type BotDeleted = { botId: BotId, crewId: CrewId, }; + +/** + * A crew that was deleted with every bot in it (spec 7.6): the result of + * `crews.delete` and the params of the `crew.deleted` notification. + */ +export type CrewDeleted = { crewId: CrewId, }; + /** * Who wrote a message. */ @@ -992,6 +1004,7 @@ export interface RpcMethods { "crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew }; "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; + "crews.delete": { params: CrewIdParams; result: CrewDeleted }; "bots.list": { params: BotsListParams; result: Array }; "bots.create": { params: BotsCreateParams; result: Bot }; "bots.update": { params: BotsUpdateParams; result: Bot }; @@ -999,6 +1012,7 @@ export interface RpcMethods { "bots.setPermissionMode": { params: BotsSetPermissionModeParams; result: Bot }; "bots.setModel": { params: BotsSetModelParams; result: Bot }; "bots.archive": { params: BotIdParams; result: Bot }; + "bots.delete": { params: BotIdParams; result: BotDeleted }; "bots.restart": { params: BotsRestartParams; result: Bot }; "chat.history": { params: ChatHistoryParams; result: Array }; "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; @@ -1030,6 +1044,8 @@ export interface RpcMethods { export interface RpcNotifications { "crew.changed": Crew; "bot.changed": Bot; + "crew.deleted": CrewDeleted; + "bot.deleted": BotDeleted; "bot.state": BotStateChanged; "chat.item": ChatItemChanged; "chat.delta": ChatDelta; diff --git a/app/src/store/app.test.ts b/app/src/store/app.test.ts index 5348a7ff..63fbea24 100644 --- a/app/src/store/app.test.ts +++ b/app/src/store/app.test.ts @@ -50,6 +50,68 @@ describe("app store", () => { expect(store.getState().selectedCrewId).toBeNull(); }); + test("a deleted bot leaves with its routines, tasks and deliveries", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + await fake.call("crews.setLead", { crewId: crew.id, botId: scout.id }); + const routine = await fake.call("routines.create", { + botId: scout.id, + name: "Morning", + prompt: "Summarize", + schedule: { kind: "interval", minutes: 60 }, + timezone: "UTC", + }); + const { store } = await synced(fake); + const task = fake.conversation.task(writer.id, scout.id); + const sent = await fake.call("messages.send", { botId: scout.id, body: "Hello" }); + store.getState().selectBot(scout.id); + expect(store.getState().tasks[task.id]).toBeDefined(); + expect(store.getState().deliveries[sent.id]).toBeDefined(); + + expect(await fake.call("bots.delete", { botId: scout.id })).toEqual({ + botId: scout.id, + crewId: crew.id, + }); + const state = store.getState(); + expect(state.bots[scout.id]).toBeUndefined(); + expect(state.bots[writer.id]).toBeDefined(); + expect(state.routines[routine.id]).toBeUndefined(); + expect(state.tasks[task.id]).toBeUndefined(); + expect(state.deliveries[sent.id]).toBeUndefined(); + expect(state.crews[crew.id]?.leadBotId).toBeNull(); + expect(state.selectedBotId).toBeNull(); + expect(state.selectedCrewId).toBe(crew.id); + await expect(fake.call("bots.delete", { botId: scout.id })).rejects.toThrow(); + }); + + test("a deleted crew leaves with its bots, archived or not", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const docs = fake.addCrew("Docs"); + const scout = fake.addBot(ops.id, "Scout"); + const old = fake.addBot(ops.id, "Old"); + const editor = fake.addBot(docs.id, "Editor"); + await fake.call("bots.archive", { botId: old.id }); + const { store } = await synced(fake); + store.getState().selectBot(scout.id); + + await fake.call("crews.delete", { crewId: ops.id }); + const state = store.getState(); + expect(Object.keys(state.crews)).toEqual([docs.id]); + expect(Object.keys(state.bots)).toEqual([editor.id]); + expect(state.selectedCrewId).toBeNull(); + expect(state.selectedBotId).toBeNull(); + expect(fake.bots.has(old.id)).toBe(false); + + // A delete the call returned is applied without waiting for the news. + store.getState().dropBot(editor.id); + store.getState().dropCrew(docs.id); + expect(store.getState().bots).toEqual({}); + expect(store.getState().crews).toEqual({}); + }); + test("reloads everything when the connection comes back", async () => { const fake = new FakeBotloft(); const crew = fake.addCrew("Ops"); diff --git a/app/src/store/app.ts b/app/src/store/app.ts index eb2af0fe..f77fb36d 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -50,6 +50,9 @@ export interface AppState { /** Applies a record a call returned, before its notification arrives. */ putCrew(crew: Crew): void; putBot(bot: Bot): void; + /** Applies a delete a call returned, before its notification arrives. */ + dropCrew(crewId: CrewId): void; + dropBot(botId: BotId): void; putDelivery(delivery: Delivery): void; putRoutine(routine: Routine): void; putBrowser(browser: BrowserState): void; @@ -116,6 +119,8 @@ export function createAppStore(api: BotloftApi): AppStore { }, putCrew: (crew) => set((state) => withCrew(state, crew)), putBot: (bot) => set((state) => withBot(state, bot)), + dropCrew: (crewId) => set((state) => withoutCrew(state, crewId)), + dropBot: (botId) => set((state) => withoutBots(state, [botId])), putRoutine: (routine) => set((state) => withRoutine(state, routine)), putBrowser: (browser) => set((state) => withBrowser(state, browser)), putSettings: (settings) => set({ settings }), @@ -149,12 +154,55 @@ function withBot(state: AppState, bot: Bot): Partial { return { bots, selectedBotId: state.selectedBotId === bot.id ? null : state.selectedBotId }; } +/** Without the entries of `record` that fail `keep`. */ +function kept(record: Record, keep: (value: T) => boolean): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => keep(value))); +} + +/** + * Deleted bots leave with what was theirs (spec 7.6): the daemon sends no + * notification for each routine, task and delivery that went with them. + */ +function withoutBots(state: AppState, botIds: BotId[]): Partial { + const gone = new Set(botIds); + return { + bots: kept(state.bots, (bot) => !gone.has(bot.id)), + routines: kept(state.routines, (routine) => !gone.has(routine.botId)), + browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)), + deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)), + tasks: kept( + state.tasks, + (task) => !gone.has(task.requesterBotId) && !gone.has(task.assigneeBotId), + ), + selectedBotId: + state.selectedBotId !== null && gone.has(state.selectedBotId) ? null : state.selectedBotId, + }; +} + +function withoutCrew(state: AppState, crewId: CrewId): Partial { + const bots = Object.values(state.bots).filter((bot) => bot.crewId === crewId); + const selected = state.selectedCrewId === crewId; + return { + ...withoutBots( + state, + bots.map((bot) => bot.id), + ), + crews: kept(state.crews, (crew) => crew.id !== crewId), + selectedCrewId: selected ? null : state.selectedCrewId, + selectedBotId: selected ? null : state.selectedBotId, + }; +} + export function applyEvent(state: AppState, event: ServerEvent): Partial | null { switch (event.name) { case "crew.changed": return withCrew(state, event.params); + case "crew.deleted": + return withoutCrew(state, event.params.crewId); case "bot.changed": return withBot(state, event.params); + case "bot.deleted": + return withoutBots(state, [event.params.botId]); case "bot.state": { const bot = state.bots[event.params.botId]; if (!bot) { diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 7e5617a0..c7a302a7 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -125,6 +125,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -223,6 +225,13 @@ fn export_bindings() { &crew, ); out.method(method::CREWS_ARCHIVE, &out.name::(), &crew); + let crew_deleted = out.name::(); + let bot_deleted = out.name::(); + out.method( + method::CREWS_DELETE, + &out.name::(), + &crew_deleted, + ); out.method(method::BOTS_LIST, &out.name::(), &bots); out.method(method::BOTS_CREATE, &out.name::(), &bot); out.method(method::BOTS_UPDATE, &out.name::(), &bot); @@ -242,6 +251,11 @@ fn export_bindings() { &bot, ); out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); + out.method( + method::BOTS_DELETE, + &out.name::(), + &bot_deleted, + ); out.method(method::BOTS_RESTART, &out.name::(), &bot); out.method( method::CHAT_HISTORY, @@ -304,6 +318,16 @@ fn export_bindings() { out.text.push_str("export interface RpcNotifications {\n"); let _ = writeln!(out.text, " \"{}\": {crew};", notification::CREW_CHANGED); let _ = writeln!(out.text, " \"{}\": {bot};", notification::BOT_CHANGED); + let _ = writeln!( + out.text, + " \"{}\": {crew_deleted};", + notification::CREW_DELETED + ); + let _ = writeln!( + out.text, + " \"{}\": {bot_deleted};", + notification::BOT_DELETED + ); let state = out.name::(); let item = out.name::(); let delta = out.name::(); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 43054c83..60b971bd 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -89,6 +89,7 @@ pub mod method { pub const CREWS_SET_WORK_FOLDER: &str = "crews.setWorkFolder"; pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; + pub const CREWS_DELETE: &str = "crews.delete"; pub const BOTS_LIST: &str = "bots.list"; pub const BOTS_CREATE: &str = "bots.create"; pub const BOTS_UPDATE: &str = "bots.update"; @@ -96,6 +97,7 @@ pub mod method { pub const BOTS_SET_PERMISSION_MODE: &str = "bots.setPermissionMode"; pub const BOTS_SET_MODEL: &str = "bots.setModel"; pub const BOTS_ARCHIVE: &str = "bots.archive"; + pub const BOTS_DELETE: &str = "bots.delete"; pub const BOTS_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; pub const APPROVALS_ANSWER: &str = "approvals.answer"; @@ -128,7 +130,9 @@ pub mod method { /// Server notification method names. pub mod notification { pub const CREW_CHANGED: &str = "crew.changed"; + pub const CREW_DELETED: &str = "crew.deleted"; pub const BOT_CHANGED: &str = "bot.changed"; + pub const BOT_DELETED: &str = "bot.deleted"; pub const BOT_STATE: &str = "bot.state"; pub const CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 7d890865..169ca45f 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -160,3 +160,22 @@ pub struct BotStateChanged { pub state: BotState, pub generation: Option, } + +/// A bot that was deleted for good (spec 7.6): the result of `bots.delete` +/// and the params of the `bot.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotDeleted { + pub bot_id: BotId, + pub crew_id: CrewId, +} + +/// A crew that was deleted with every bot in it (spec 7.6): the result of +/// `crews.delete` and the params of the `crew.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewDeleted { + pub crew_id: CrewId, +} From c4ca159de80c2f4a9e5fbe89e286220b6c5103f7 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:40:13 -0300 Subject: [PATCH 3/6] feat(daemon): delete bots and crews for good bots.delete and crews.delete work on active and archived items (spec 7.6). The process is killed at once and the supervisor forgets the bot; the database loses the bot and what was its own; folders stay on disk (spec 5). Other bots are told about the unfinished tasks a deleted bot leaves, and a message of its that still waited arrives as from a deleted bot. The spec row that said archiving deletes the workspace was never true and now says what happens. --- crates/botloft-core/src/envelope.rs | 13 + crates/botloftd/src/approvals/mod.rs | 8 + crates/botloftd/src/courier/render.rs | 16 +- crates/botloftd/src/routines/mod.rs | 5 + crates/botloftd/src/rpc/dispatch.rs | 6 +- crates/botloftd/src/rpc/mod.rs | 2 + crates/botloftd/src/screens/mod.rs | 6 + crates/botloftd/src/service/delete.rs | 149 +++++++++++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/src/service/tasks.rs | 2 +- crates/botloftd/src/state.rs | 6 +- crates/botloftd/src/supervisor/forget.rs | 36 +++ crates/botloftd/src/supervisor/mod.rs | 5 +- crates/botloftd/src/supervisor/reconcile.rs | 4 + crates/botloftd/tests/delete.rs | 259 ++++++++++++++++++++ crates/botloftd/tests/delete_messages.rs | 143 +++++++++++ docs/spec.md | 36 ++- 17 files changed, 675 insertions(+), 22 deletions(-) create mode 100644 crates/botloftd/src/service/delete.rs create mode 100644 crates/botloftd/src/supervisor/forget.rs create mode 100644 crates/botloftd/tests/delete.rs create mode 100644 crates/botloftd/tests/delete_messages.rs diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 9e4c5249..09f5971a 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -10,6 +10,9 @@ pub enum Sender<'a> { Bot { handle: &'a str, }, + /// A bot the owner deleted after it sent the message (spec 7.6): there + /// is nobody to reply to. + DeletedBot, /// The daemon, e.g. to say a task expired. Botloft, } @@ -62,6 +65,7 @@ impl Envelope<'_> { // No `@`: a bot may well be called "Owner". Sender::Owner => "the owner".to_owned(), Sender::Bot { handle } => format!("@{handle}"), + Sender::DeletedBot => "a deleted bot".to_owned(), Sender::Botloft => "Botloft".to_owned(), } } @@ -168,6 +172,15 @@ mod tests { ); } + #[test] + fn a_deleted_sender_cannot_be_answered() { + let text = envelope(Sender::DeletedBot, MessageKind::Note, None).render(0); + assert_eq!( + text, + "[botloft] from a deleted bot · crew Site\n\nCheck the build.\nThanks!" + ); + } + #[test] fn results_and_notices_carry_the_task_status() { let done = task(0, TaskStatus::Done); diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index bc0e4411..bec38887 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -57,6 +57,14 @@ impl Approvals { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// Lets go of requests nobody will answer: their bot was deleted. + pub(crate) fn forget(&self, requests: &[ApprovalId]) { + let mut waiting = self.lock(); + for request in requests { + waiting.remove(request); + } + } } /// Puts a request in the bot's chat and waits for the owner. `None` if it diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index 6da1efeb..2283adb7 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -39,14 +39,14 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend let text = match message.from_kind { // The owner is the session's user: their words go as written. SenderKind::Owner => with_attachment_list(&message.body, &message.attachments), - SenderKind::Bot => envelope( - message, - context, - Sender::Bot { - handle: context.sender_handle.unwrap_or("unknown"), - }, - now, - ), + SenderKind::Bot => { + let from = match context.sender_handle { + Some(handle) => Sender::Bot { handle }, + // Deleted while the message waited (spec 7.6). + None => Sender::DeletedBot, + }; + envelope(message, context, from, now) + } SenderKind::System => match context.routine { Some((name, scheduled, timezone)) => RoutineEnvelope { name, diff --git a/crates/botloftd/src/routines/mod.rs b/crates/botloftd/src/routines/mod.rs index bce735bc..640a20b2 100644 --- a/crates/botloftd/src/routines/mod.rs +++ b/crates/botloftd/src/routines/mod.rs @@ -43,6 +43,11 @@ impl Routines { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// The bot was deleted, and its runs with it. + pub(crate) fn forget(&self, bot: &BotId) { + self.turns().remove(bot); + } } /// Runs the scheduler until the daemon stops. The first pass also ends the diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 4f62a504..9d37c7e0 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,8 +8,8 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, - modes, routines, screens, settings, tasks, + self, ApiResult, attachments, bots, chat, crews, delete, deliveries, files, lead, messages, + models, modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -28,6 +28,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(crews::set_work_folder(daemon, parse(params)?)), method::CREWS_SET_LEAD => reply(lead::set_lead(daemon, parse(params)?)), method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), + method::CREWS_DELETE => reply(delete::crew(daemon, parse(params)?)), method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)), method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)), method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)), @@ -37,6 +38,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(models::set_model(daemon, parse(params)?)), method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)), + method::BOTS_DELETE => reply(delete::bot(daemon, parse(params)?)), method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index b5105ef1..8b935c2b 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -199,7 +199,9 @@ fn handle( fn to_notification(event: &Event) -> String { let (name, params) = match event { Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), + Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), + Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)), Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), diff --git a/crates/botloftd/src/screens/mod.rs b/crates/botloftd/src/screens/mod.rs index cc9804e0..0e49d406 100644 --- a/crates/botloftd/src/screens/mod.rs +++ b/crates/botloftd/src/screens/mod.rs @@ -79,6 +79,12 @@ impl Screens { .clone() } + /// The bot was deleted: its key stops working and its drafts go. + pub(crate) fn forget(&self, bot: &BotId) { + self.lock_keys().remove(bot); + self.drafts().remove(bot); + } + /// The bot a key belongs to. pub fn bot_of(&self, key: &str) -> Option { if key.is_empty() { diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs new file mode 100644 index 00000000..1efdad1e --- /dev/null +++ b/crates/botloftd/src/service/delete.rs @@ -0,0 +1,149 @@ +//! `bots.delete` and `crews.delete` (spec 7.6): removing a bot or a crew +//! for good, active or archived. Archiving keeps everything in the +//! database; deleting keeps only the folders on disk (spec 5). + +use botloft_core::ids::{BotId, MessageId}; +use botloft_core::protocol::{ + BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind, +}; +use botloft_store::{BotRecord, Store, TaskFilter}; +use tracing::{info, warn}; + +use super::messages::post; +use super::tasks::UNFINISHED; +use super::{ApiResult, bots, crews, lead}; +use crate::state::{Daemon, Event}; + +/// Deletes the bot: its process stops at once and its conversation, +/// routines and tasks leave the database. Its folder stays. +pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { + let store = daemon.store(); + let record = bots::find(&store, ¶ms.bot_id)?; + let crew = crews::find(&store, &record.crew_id)?; + let notices = task_notices(&store, &crew, &record)?; + let waiting = store.pending_approvals(&record.id)?; + store.delete_bot(&record.id)?; + stop(daemon, &record.id); + + if lead::is_lead(&crew, &record.id) { + // The crew lost its chief with the bot. + crews::changed(daemon, crews::find(&store, &crew.id)?); + } + let deleted = BotDeleted { + bot_id: record.id, + crew_id: crew.id.clone(), + }; + daemon.emit(Event::BotDeleted(deleted.clone())); + for (to, body) in notices { + tell(daemon, &store, &crew, to, body); + } + drop(store); + daemon.approvals.forget(&waiting); + release(daemon, &deleted.bot_id); + info!(bot = %deleted.bot_id, "bot deleted"); + Ok(deleted) +} + +/// Deletes the crew with every bot in it, archived ones too. Its folders +/// stay. +pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { + let store = daemon.store(); + let crew = crews::find(&store, ¶ms.crew_id)?; + let bots = store.bots(Some(&crew.id), true)?; + let mut waiting = Vec::new(); + for bot in &bots { + waiting.extend(store.pending_approvals(&bot.id)?); + } + store.delete_crew(&crew.id)?; + for bot in &bots { + stop(daemon, &bot.id); + } + let deleted = CrewDeleted { crew_id: crew.id }; + daemon.emit(Event::CrewDeleted(deleted.clone())); + drop(store); + daemon.approvals.forget(&waiting); + for bot in &bots { + release(daemon, &bot.id); + } + info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted"); + Ok(deleted) +} + +/// Kills the bot's process and closes its browser, profile included. +fn stop(daemon: &Daemon, bot: &BotId) { + daemon.supervisor.forget(bot); + daemon.browsers.forget(bot); +} + +/// Lets go of what the daemon kept in memory for the bot. Called without +/// the store: the routines take their own lock before it. +fn release(daemon: &Daemon, bot: &BotId) { + daemon.routines.forget(bot); + daemon.screens.forget(bot); +} + +/// What the other bots should hear about the unfinished tasks a deleted +/// bot leaves (spec 7.6): who to tell, and what. Only bots that still run +/// are told. +fn task_notices(store: &Store, crew: &Crew, bot: &BotRecord) -> ApiResult> { + if crew.archived_at.is_some() { + return Ok(Vec::new()); + } + let handle = &bot.handle; + let owed = store.tasks(TaskFilter { + assignee: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let asked = store.tasks(TaskFilter { + requester: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let owed = owed.into_iter().rev().map(|task| { + let body = format!( + "Task {} for @{handle} will not be done: the owner deleted @{handle}. Get it done \ + another way, or tell the owner what is missing.", + task.id + ); + (task.requester_bot_id, body) + }); + let asked = asked.into_iter().rev().map(|task| { + let body = format!( + "Task {} from @{handle} no longer needs a result: the owner deleted @{handle}. \ + Stop working on it.", + task.id + ); + (task.assignee_bot_id, body) + }); + let mut notices = Vec::new(); + for (to, body) in owed.chain(asked) { + if store + .bot(&to)? + .is_some_and(|other| other.archived_at.is_none()) + { + notices.push((to, body)); + } + } + Ok(notices) +} + +/// A notice from Botloft to a bot of the crew. +fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { + let notice = Message { + id: MessageId::generate(), + crew_id: crew.id.clone(), + from_kind: SenderKind::System, + from_bot_id: None, + to_bot_id: to, + kind: MessageKind::System, + body, + task_id: None, + routine_id: None, + attachments: Vec::new(), + created_at: daemon.clock.now_ms(), + }; + if let Err(err) = post(daemon, store, notice, None) { + warn!(crew = %crew.id, "could not tell a bot about a deleted bot's task: {err}"); + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 7aba88e0..a3fc2343 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -5,6 +5,7 @@ pub mod attachments; pub mod bots; pub mod chat; pub mod crews; +pub mod delete; pub mod deliveries; pub mod files; pub mod lead; diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index 27355b3f..c1c18006 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -22,7 +22,7 @@ use crate::state::Daemon; /// Longest deadline a bot may set: a week. pub const MAX_DEADLINE_MINUTES: u32 = 7 * 24 * 60; /// Tasks that still expect a result. -const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; +pub(crate) const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; #[derive(Debug, Clone)] pub struct TaskSettings { diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index f947f00a..73742cfc 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, Bot, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, - Crew, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, + ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -30,7 +30,9 @@ use crate::workspace::WorkspaceEnv; #[derive(Debug, Clone, PartialEq)] pub enum Event { CrewChanged(Crew), + CrewDeleted(CrewDeleted), BotChanged(Bot), + BotDeleted(BotDeleted), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), diff --git a/crates/botloftd/src/supervisor/forget.rs b/crates/botloftd/src/supervisor/forget.rs new file mode 100644 index 00000000..0b1c84e5 --- /dev/null +++ b/crates/botloftd/src/supervisor/forget.rs @@ -0,0 +1,36 @@ +//! A deleted bot leaves the supervisor (spec 7.6). + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tracing::warn; + +use super::{Inner, Supervisor}; + +impl Supervisor { + /// The bot was deleted: its process is killed at once, without waiting + /// for its turn, and nothing of it is kept. Whatever the process still + /// prints is ignored, and its exit finds no slot. + pub fn forget(&self, bot: &BotId) { + let mut inner = self.lock(); + let Inner { + slots, + tokens, + sessions, + gone, + .. + } = &mut *inner; + // A pass that read the bot just before must not start it again. + gone.insert(bot.clone()); + sessions.remove(bot); + let Some(slot) = slots.remove(bot) else { + return; + }; + self.count_busy(slot.state, BotState::Offline); + if let Some(running) = slot.running { + tokens.remove(&running.token_hash); + if let Err(err) = running.control.kill() { + warn!(bot = %bot, "could not stop a deleted bot: {err}"); + } + } + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 0a4f83c0..d7e33098 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -5,6 +5,7 @@ //! Lock order: the store lock may be held while taking the supervisor lock //! (service calls read states), never the other way around. +mod forget; mod reconcile; mod relaunch; mod settings; @@ -13,7 +14,7 @@ mod slot; mod spawn; mod turns; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::Duration; @@ -64,6 +65,8 @@ struct Inner { /// The conversation each bot resumes, as far as this run knows; the /// database has it too (spec 7.3). sessions: HashMap, + /// Bots that were deleted while this daemon ran (spec 7.6). + gone: HashSet, claude: ClaudeStatus, sign_in: sign_in::SignIn, } diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 947a459d..1e2ce5ee 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -70,6 +70,10 @@ impl Supervisor { bot: &BotRecord, now: Instant, ) { + // Deleted after this pass read the database. + if inner.gone.contains(&bot.id) { + return; + } let archived = bot.archived_at.is_some() || crew.archived_at.is_some(); if archived && !inner.slots.contains_key(&bot.id) { return; diff --git a/crates/botloftd/tests/delete.rs b/crates/botloftd/tests/delete.rs new file mode 100644 index 00000000..22edd63f --- /dev/null +++ b/crates/botloftd/tests/delete.rs @@ -0,0 +1,259 @@ +//! Deleting bots and crews for good (spec 7.6): what stops, what leaves +//! the database and what stays on disk. What the other bots hear is in +//! `delete_messages.rs`. + +mod common; + +use std::time::Duration; + +use botloft_core::ids::BotId; +use common::bots::{ready_bot, two_bots}; +use common::mcp::Mcp; +use common::stream; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +fn id_of(bot: &Value) -> BotId { + bot["id"].as_str().expect("id").parse().expect("bot id") +} + +#[tokio::test] +async fn a_deleted_bot_stops_and_leaves_only_its_folder() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let mut watcher = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (scout, process, _) = ready_bot(&t, &mut app, &crew, "Scout").await; + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "Hello" }), + ) + .await + .expect("send"); + process.wait_lines(1).await; + let workspace = t.paths.bot_workspace("ops", "scout"); + std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory"); + + let deleted = app + .call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete"); + assert_eq!( + deleted, + json!({ "botId": scout["id"], "crewId": crew["id"] }) + ); + assert_eq!(watcher.notification("bot.deleted").await, deleted); + assert!(process.killed(), "the process does not outlive the bot"); + assert_eq!(t.daemon.supervisor.status(&id_of(&scout)), None); + + assert_eq!( + app.call("bots.list", json!({})).await.expect("bots"), + json!([]) + ); + for (method, params) in [ + ("chat.history", json!({ "botId": scout["id"] })), + ("messages.list", json!({ "botId": scout["id"] })), + ("bots.delete", json!({ "botId": scout["id"] })), + ( + "messages.send", + json!({ "botId": scout["id"], "body": "Still there?" }), + ), + ] { + let gone = app.call(method, params).await.expect_err(method); + assert_eq!(gone.code, NOT_FOUND, "{method}"); + } + let timeline = app + .call("messages.list", json!({ "crewId": crew["id"] })) + .await + .expect("timeline"); + assert_eq!(timeline, json!([])); + + // The folder stays as it was, and nothing starts the bot again. + assert_eq!( + std::fs::read_to_string(workspace.join("CLAUDE.md")).expect("memory"), + "what the bot learned" + ); + t.daemon.supervisor.reconcile().await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(t.runtime.processes().len(), 1); +} + +#[tokio::test] +async fn deleting_the_chief_leaves_the_crew_without_one() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let lead = json!({ "name": "Chief", "role": "Leads", "instructions": "Ship the site." }); + let crew = app + .call("crews.create", json!({ "name": "Ops", "lead": lead })) + .await + .expect("crew"); + let mut watcher = t.session().await; + app.call("bots.delete", json!({ "botId": crew["leadBotId"] })) + .await + .expect("delete"); + let changed = watcher.notification("crew.changed").await; + assert_eq!(changed["leadBotId"], Value::Null); + let crews = app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews[0]["leadBotId"], Value::Null); +} + +#[tokio::test] +async fn archived_bots_and_crews_can_be_deleted_too() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let scout = app.call("bots.create", bot.clone()).await.expect("bot"); + app.call("bots.archive", json!({ "botId": scout["id"] })) + .await + .expect("archive"); + app.call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete an archived bot"); + + // The folder is still there, so a new bot of the same name gets another. + let again = app.call("bots.create", bot).await.expect("bot"); + assert_eq!(again["handle"], "scout"); + assert_eq!(again["slug"], "scout-2"); + + app.call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("archive"); + app.call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect("delete an archived crew"); + let gone = app + .call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect_err("already gone"); + assert_eq!(gone.code, NOT_FOUND); +} + +#[tokio::test] +async fn a_deleted_crew_takes_its_bots_and_keeps_its_folders() { + let mut c = two_bots().await; + let mut watcher = c.t.session().await; + let other = c + .app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("other crew"); + let (editor, editor_process, _) = ready_bot(&c.t, &mut c.app, &other, "Editor").await; + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "Please help.", "kind": "task" }), + ) + .await + .expect("task"); + c.writer_process.wait_lines(1).await; + let lead = bot_named(&mut c.app, "Lead").await; + let shared = c.t.paths.shared_dir("ops"); + std::fs::write(shared.join("report.md"), "the work").expect("work file"); + + let deleted = c + .app + .call("crews.delete", json!({ "crewId": c.crew["id"] })) + .await + .expect("delete"); + assert_eq!(deleted, json!({ "crewId": c.crew["id"] })); + assert_eq!(watcher.notification("crew.deleted").await, deleted); + assert!(c.lead_process.killed() && c.writer_process.killed()); + assert!(!editor_process.killed(), "other crews keep running"); + assert_eq!(c.t.daemon.supervisor.status(&id_of(&lead)), None); + + let crews = c.app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews, json!([other])); + let bots = c.app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(bots.as_array().expect("list").len(), 1); + assert_eq!(bots[0]["id"], editor["id"]); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + // A deleted bot's token opens nothing. + let refused = c.writer.request("ping", json!({})).await; + assert_eq!(refused.status, 401); + + assert_eq!( + std::fs::read_to_string(shared.join("report.md")).expect("work file"), + "the work" + ); + assert!(c.t.paths.bot_workspace("ops", "lead").is_dir()); +} + +#[tokio::test] +async fn a_request_waiting_for_the_owner_goes_with_the_bot() { + let mut c = two_bots().await; + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": "Build it" }), + ) + .await + .expect("send"); + let line = c.lead_process.wait_lines(1).await[0].clone(); + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::tool_use( + "toolu_1", + "Bash", + json!({ "command": "make" }), + )) + .await; + let mut asking = Mcp::new(c.lead.addr, c.lead.token.clone()); + let args = + json!({ "tool_name": "Bash", "input": { "command": "make" }, "tool_use_id": "toolu_1" }); + let decision = tokio::spawn(async move { + asking + .tool("permission_prompt", args) + .await + .expect("answer") + }); + let request = loop { + let item = c.app.notification("chat.item").await; + if item["item"]["body"]["kind"] == "approval" { + break item["item"]["body"]["approvalId"].clone(); + } + }; + + let started = std::time::Instant::now(); + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + let outcome = decision.await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); + assert!( + started.elapsed() < Duration::from_secs(2), + "the request did not wait for its 3 s timeout" + ); + let gone = c + .app + .call( + "approvals.answer", + json!({ "approvalId": request, "allow": true }), + ) + .await + .expect_err("the request went with the bot"); + assert_eq!(gone.code, NOT_FOUND); +} diff --git a/crates/botloftd/tests/delete_messages.rs b/crates/botloftd/tests/delete_messages.rs new file mode 100644 index 00000000..a8e92835 --- /dev/null +++ b/crates/botloftd/tests/delete_messages.rs @@ -0,0 +1,143 @@ +//! What the other bots hear when a bot is deleted (spec 7.6): notices +//! about the tasks it leaves, and its messages that still waited. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use common::Client; +use common::bots::{text_of, two_bots}; +use serde_json::{Value, json}; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +#[tokio::test] +async fn the_other_bots_hear_about_the_tasks_a_deleted_bot_leaves() { + let mut c = two_bots().await; + let ask = |to: &str| json!({ "to": to, "body": "Please help.", "kind": "task" }); + let owed = c + .writer + .tool("send_message", ask("lead")) + .await + .expect("task for the lead"); + let asked = c + .lead + .tool("send_message", ask("writer")) + .await + .expect("task for the writer"); + let (owed, asked) = ( + owed["task_id"].as_str().expect("id").to_owned(), + asked["task_id"].as_str().expect("id").to_owned(), + ); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + c.writer_process.wait_lines(2).await; + + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + let lines = c.writer_process.wait_lines(4).await; + assert_eq!( + text_of(&lines[2]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {owed} for @lead will not be done: the \ + owner deleted @lead. Get it done another way, or tell the owner what is missing." + ) + ); + assert_eq!( + text_of(&lines[3]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {asked} from @lead no longer needs a \ + result: the owner deleted @lead. Stop working on it." + ) + ); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + let gone = c + .writer + .tool( + "complete_task", + json!({ "task_id": asked, "result": "Done anyway." }), + ) + .await + .expect_err("the task went with the bot"); + assert!(gone.contains("does not exist"), "{gone}"); + + // What the lead wrote stays in the writer's chat, without a sender. + let writer = bot_named(&mut c.app, "Writer").await; + let kept = c + .app + .call("messages.list", json!({ "botId": writer["id"] })) + .await + .expect("messages"); + let kept: Vec<_> = kept.as_array().expect("list").iter().rev().collect(); + assert_eq!(kept.len(), 4); + for message in &kept[..2] { + assert_eq!(message["fromKind"], "bot"); + assert_eq!(message["fromBotId"], Value::Null); + assert_eq!(message["taskId"], Value::Null); + } + assert_eq!(kept[1]["body"], "The draft is in shared/."); + assert_eq!(kept[2]["fromKind"], "system"); +} + +#[tokio::test] +async fn a_message_still_waiting_arrives_from_a_deleted_bot() { + let mut c = two_bots().await; + let writer = bot_named(&mut c.app, "Writer").await; + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": true }), + ) + .await + .expect("pause"); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": false }), + ) + .await + .expect("resume"); + // The lead's, the writer's first and the one the writer resumes in. + let process = c.t.runtime.process(3).await; + c.t.until_state(&writer, BotState::Idle).await; + c.t.clock.advance(Duration::from_secs(5)); + let lines = process.wait_lines(1).await; + assert_eq!( + text_of(&lines[0]), + "[botloft] from a deleted bot · crew Ops\n\nThe draft is in shared/." + ); +} diff --git a/docs/spec.md b/docs/spec.md index 1be68ac7..da94073c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,6 +117,8 @@ Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer **Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. +**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. + Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. Slug: ASCII minúsculo com hífens, acentos transliterados (`Revisão` -> `revisao`), no máximo 32 caracteres, nunca um nome de dispositivo do Windows (`con`, `lpt1`...) e nunca `shared` para bot. Colisão ganha sufixo (`docs-2`), inclusive com slug de item arquivado ou pasta que já exista no disco. O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha renomeações e é único entre os bots ativos da crew; um nome que gere handle já usado é recusado com erro de validação. @@ -267,6 +269,20 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. - Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual. +### 7.6 Arquivar e excluir + +**Arquivar** (`bots.archive`, `crews.archive`) tira o bot ou a crew do app e guarda tudo no banco: o processo para, o token é revogado, as rotinas são arquivadas junto (20.3) e o perfil do navegador é apagado (21.2). + +**Excluir** (`bots.delete`, `crews.delete`) apaga do banco, de vez, e vale para ativos e arquivados. Não tem volta, e nenhuma pasta é apagada (5). + +- **O bot para na hora.** O processo é morto sem esperar o turno, o token deixa de valer, o navegador fecha e o perfil dele é apagado, e os pedidos de permissão abertos caem. O supervisor esquece o bot: o que o processo ainda imprimir é ignorado, e uma reconciliação que tinha lido o bot antes não o sobe de novo. +- **Sai do banco** (12), numa transação: o bot, o chat dele, as aprovações, as messages que ele **recebeu** (com deliveries e o registro dos anexos), as rotinas com as execuções, os sites liberados no navegador e as tasks que ele pediu ou recebeu. Se era o chefe, a crew fica sem chefe (10.2). +- **Fica com os outros bots:** as messages que o excluído **mandou** continuam no chat de quem recebeu e na timeline da crew, sem remetente (`fromBotId: null`) e sem task. Uma que ainda esperava na fila chega com `from a deleted bot` e sem instrução de resposta (9.3). +- **Tasks em aberto** (`open` ou `expired`) somem com o bot, e o outro lado é avisado pelo daemon (`from Botloft`), se ainda está ativo: quem pediu uma task ao excluído lê que ela não vai ser feita; quem fazia uma task para o excluído lê que ninguém mais espera o resultado. Um `complete_task` dela responde que a task não existe. +- **Excluir uma crew** exclui todos os bots dela, ativos e arquivados, com tudo acima, e a crew. Ninguém é avisado: não sobra ninguém. +- **Avisos ao app:** `bot.deleted {botId, crewId}` e `crew.deleted {crewId}` (11.3). O app tira do store o bot (ou a crew com os bots) e o que era dele: rotinas, tasks, deliveries e o navegador. As outras coisas que mudam saem pelos avisos de sempre (`crew.changed` sem chefe, `message.created` dos avisos de task). +- **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. + ## 8. Chat O chat de um bot é a sequência de itens que o daemon monta a partir do que entra no stdin e do que sai no stdout. Ele fica no banco (`chat_items`), é paginado pelo app e é a única visão da conversa: não há terminal. @@ -373,7 +389,8 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - Nota de outro bot: primeira linha `[botloft] from @revisor · crew Exemplo` e só a instrução de resposta. - Resultado de task: `· result of task tsk_... · done` (ou `failed`) e a instrução de resposta. -- Aviso do daemon (task vencida): `from Botloft` e o texto do aviso, sem instrução. +- Aviso do daemon (task vencida, task de um bot excluído): `from Botloft` e o texto do aviso, sem instrução. +- Message de um bot que o dono excluiu enquanto ela esperava na fila (7.6): `from a deleted bot`, sem instrução de resposta, porque não há a quem responder. - Rotina: `routine "" · scheduled ()` e o aviso de que ninguém está olhando (20.5). - O prazo é relativo (`due in 45 min`, `due in 2 h`, `overdue`) e calculado na hora do envio: o bot não sabe a hora atual, e o app mostra o horário absoluto a partir de `deadline_at`. @@ -386,6 +403,7 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - `complete_task` só vale para quem recebeu a task, com status `open` ou `expired`. Grava o resultado e, na mesma transação, cria a message `result` para o solicitante. - Task vencida vira `expired` no ciclo do courier, e o solicitante recebe um aviso do daemon (`from Botloft`). Ela continua aceitando resultado atrasado. - `cancelled` fica reservado: nenhuma tool cancela task no MVP. +- Excluir um bot apaga as tasks que ele pediu ou recebeu e avisa o outro lado das que estavam em aberto (7.6). ### 9.5 Anexos @@ -451,7 +469,7 @@ A aprovação só abre se `tool_use_id` for de uma ferramenta em `running` no ch ### 10.2 Chefe e sugestões de bots -Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e esse bot juntos, e `Crew.leadBotId` aponta para ele. O app escreve nome e papel no idioma do dono ("Chefe", "Lidera a equipe: planeja o trabalho, sugere bots novos e distribui as tarefas"); as instruções são o objetivo que o dono escreveu para a crew. O chefe é gravado antes de o supervisor poder subi-lo, então o primeiro start já lê que lidera. O dono troca o chefe (`crews.setLead`) ou deixa a crew sem nenhum; o chefe antigo e o novo têm as regras regravadas e reiniciam quando nada estiver em andamento (7.4). Arquivar o chefe deixa a crew sem chefe. Crews de antes não têm chefe até o dono escolher um. +Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e esse bot juntos, e `Crew.leadBotId` aponta para ele. O app escreve nome e papel no idioma do dono ("Chefe", "Lidera a equipe: planeja o trabalho, sugere bots novos e distribui as tarefas"); as instruções são o objetivo que o dono escreveu para a crew. O chefe é gravado antes de o supervisor poder subi-lo, então o primeiro start já lê que lidera. O dono troca o chefe (`crews.setLead`) ou deixa a crew sem nenhum; o chefe antigo e o novo têm as regras regravadas e reiniciam quando nada estiver em andamento (7.4). Arquivar ou excluir o chefe deixa a crew sem chefe. Crews de antes não têm chefe até o dono escolher um. - **Regras do chefe:** além das de todo bot, uma seção "You lead this crew": planejar, dividir o trabalho em partes que rodam em paralelo e distribuí-las com `send_message` (`kind: "task"`); conferir os resultados antes de responder ao dono; quando faltar um especialista, sugerir um bot com `suggest_bot`; escolher o modelo pelo trabalho (`haiku` para o simples e repetitivo, `sonnet` para quase tudo, `opus` ou `fable` só para o raciocínio mais difícil, lembrando que gastam mais do plano); manter a crew pequena; e contar ao dono o que cada bot faz, para ele acompanhar e falar com cada um no chat dele. Os outros bots leem que, se a crew precisar de outro bot, devem pedir ao chefe, que o `crew_roster` marca com `chief`. - **`suggest_bot`:** aparece para todos os bots, mas só o chefe pode usar; os outros recebem um erro que manda pedir ao chefe. Antes de incomodar o dono, o daemon confere os campos (os de um bot, mais o limite de instruções e o porquê), que o nome está livre na crew e que a crew tem menos de `max_per_crew` bots ativos. Um pedido impossível volta como erro ao chefe. @@ -485,6 +503,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) | | `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) | | `crews.archive` | `crewId` | `Crew` | +| `crews.delete` | `crewId` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela (7.6) | | `bots.list` | `crewId?` | `Bot[]` | | `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | | `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` | @@ -493,6 +512,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.setModel` | `botId, model` (`default`, `fable`, `opus`, `sonnet`, `haiku`) | `Bot`; o bot reinicia no novo modelo quando nada estiver em andamento (7.4) | | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | +| `bots.delete` | `botId` | `{botId, crewId}`; exclui o bot, ativo ou arquivado (7.6) | | `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | | `approvals.answer` | `approvalId, allow, note?, input?` (`input`: a sugestão de bot como o dono a deixou, 10.2) | `Approval` | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | @@ -512,7 +532,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `crew.changed`, `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). +`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). ### 11.4 Erros @@ -524,11 +544,11 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `-32004` | validação | | `-32005` | runtime indisponível (claude ausente ou versão antiga) | -Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-32600` (request inválido), `-32601` (método desconhecido), `-32602` (params inválidos) e `-32603` (erro interno; a mensagem não traz corpo de mensagem nem token). `crews.archive` e `bots.archive` são idempotentes. +Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-32600` (request inválido), `-32601` (método desconhecido), `-32602` (params inválidos) e `-32603` (erro interno; a mensagem não traz corpo de mensagem nem token). `crews.archive` e `bots.archive` são idempotentes. `crews.delete` e `bots.delete` não: o que já foi excluído não existe mais, e a segunda chamada dá `-32002`, como qualquer outro método com esse id. ## 12. Dados (SQLite) -Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). +Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`approvals`, `chat_items`, `browser_sites`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. | Tabela | Colunas principais | |---|---| @@ -573,7 +593,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Processos órfãos | Job Object por bot (seção 7.3) | | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | -| Arquivo em uso | arquivar bot só apaga workspace depois do processo encerrado; retentar exclusão se bloqueado | +| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos | | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | | Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) | @@ -908,7 +928,7 @@ Cada disparo vira uma `routine_run`, com o horário marcado (`scheduled_for`), u - **Sobreposição:** o horário chega com a execução anterior ainda `queued` (o bot está lento, parado por limite de uso, sem login ou fora do ar). - `skip` (padrão): registra `skipped` com `reason: overlap`. Um bot lento ou fora do ar não acumula pedidos repetidos. - `queue`: grava mesmo assim, mas só uma execução espera atrás da aberta; as outras viram `skipped`. -- **Bot ou crew pausados:** o horário vira `skipped` (`bot_paused`); a rotina não guarda pedidos para quando voltar. Bot ou crew arquivados: a rotina é arquivada junto. +- **Bot ou crew pausados:** o horário vira `skipped` (`bot_paused`); a rotina não guarda pedidos para quando voltar. Bot ou crew arquivados: a rotina é arquivada junto. Excluídos: a rotina e as execuções são apagadas (7.6). - **Rodar agora:** `routines.runNow` cria uma execução fora de hora (`scheduled_for` = agora), sem mexer no próximo horário, e vale a mesma regra de sobreposição. ### 20.4 Horários perdidos @@ -1019,7 +1039,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - Comando: `msedge.exe --headless=new --remote-debugging-port=0 --user-data-dir=\browsers\ --no-first-run --no-default-browser-check --mute-audio --disable-extensions --disable-sync about:blank`. Sem `--disable-extensions`, um perfil novo recebe as extensões instaladas para todo o computador, e elas abrem abas próprias no navegador do bot (visto com o Edge 154). Com a porta 0, o Edge escolhe uma porta livre em 127.0.0.1 e a escreve com o caminho do WebSocket em `DevToolsActivePort`, na pasta do perfil (visto com o Edge 154); o daemon apaga o arquivo antigo antes de subir e espera o novo por até 10 s. - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). - Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. -- **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar o bot fecha o navegador e apaga o perfil. +- **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. - **Fechar:** depois de `[browser] idle_minutes` sem tool do bot e sem ninguém assistindo; quando o bot ou a crew pausa; com `browser_close`. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. - Se o processo cai ou o WebSocket fecha, o estado vira `closed` e a próxima tool sobe de novo. From 1a5b41419ed57f5d24b06acf73495bcdd5b7bbbe Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:47:54 -0300 Subject: [PATCH 4/6] refactor(app): a bot's menu actions in one place useBotActions holds the items of the bot's menu and the dialogs they open, so another menu can offer exactly the same. --- app/src/features/bots/BotHeader.tsx | 80 +++--------------------- app/src/features/bots/botActions.tsx | 91 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 app/src/features/bots/botActions.tsx diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index d92d78c4..8ab746ca 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -1,34 +1,25 @@ import { - Archive, - Crown, Ellipsis, Files, - FolderOpen, Globe, LayoutTemplate, PanelRight, Pause, - Pencil, Play, - RefreshCcw, RotateCw, ShieldOff, } from "lucide-react"; -import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; -import { useApi, useApp, useHost } from "../../store/context"; +import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotAvatar, moodOf } from "./BotAvatar"; -import { BotDialog } from "./BotDialog"; import { BotStateBadge } from "./BotStateBadge"; +import { useBotActions } from "./botActions"; import { ChiefBadge, isChief } from "./ChiefBadge"; -type Open = "edit" | "archive" | "fresh" | null; - /** The bot's name, state and actions. */ export function BotHeader({ bot, @@ -68,25 +59,18 @@ export function BotHeader({ const t = useT(); const words = t.bots.header; const api = useApi(); - const host = useHost(); const putBot = useApp((state) => state.putBot); - const putCrew = useApp((state) => state.putCrew); const chief = isChief(bot, crew); - const [open, setOpen] = useState(null); - const close = () => setOpen(null); + const actions = useBotActions(bot, crew); const stopped = bot.paused || crew.paused; const setPaused = (paused: boolean) => attempt(paused ? words.failed.pause : words.failed.resume, async () => putBot(await api.call("bots.setPaused", { botId: bot.id, paused })), ); - const setChief = (on: boolean) => - attempt(words.failed.chief, async () => - putCrew(await api.call("crews.setLead", { crewId: crew.id, botId: on ? bot.id : null })), - ); - const restart = (fresh: boolean) => + const restart = () => attempt(words.failed.restart, async () => - putBot(await api.call("bots.restart", { botId: bot.id, fresh })), + putBot(await api.call("bots.restart", { botId: bot.id, fresh: false })), ); return ( @@ -123,7 +107,7 @@ export function BotHeader({ {words.pause} )} - @@ -202,56 +186,8 @@ export function BotHeader({ aria-pressed={detailsOpen} onClick={onToggleDetails} /> - setOpen("edit") }, - { - label: chief ? words.stopChief : words.makeChief, - icon: Crown, - onSelect: () => setChief(!chief), - }, - { - label: words.restartFresh, - icon: RefreshCcw, - disabled: stopped, - onSelect: () => setOpen("fresh"), - }, - { - label: words.openFolder, - icon: FolderOpen, - onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), - }, - { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, - ]} - /> - - {open === "edit" && } - {open === "fresh" && ( - restart(true)} - > - {words.fresh.body(bot.name)} - - )} - {open === "archive" && ( - - attempt(words.failed.archive, async () => - putBot(await api.call("bots.archive", { botId: bot.id })), - ) - } - > - {words.archiveConfirm.body} - - )} + + {actions.dialogs} ); } diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx new file mode 100644 index 00000000..246b2c9d --- /dev/null +++ b/app/src/features/bots/botActions.tsx @@ -0,0 +1,91 @@ +import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } 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 type { MenuItem } from "../../ui/Menu"; +import { attempt } from "../../ui/toast"; +import { BotDialog } from "./BotDialog"; +import { isChief } from "./ChiefBadge"; + +type Open = "edit" | "archive" | "fresh" | null; + +/** + * What the owner can do with a bot from a menu, and the dialogs those + * actions open (`null` while none is). The header's menu and the right-click + * menu of the conversation list both come from here, so they stay the same. + */ +export function useBotActions( + bot: Bot, + crew: Crew, +): { items: MenuItem[]; dialogs: ReactNode | null } { + const t = useT(); + const words = t.bots.header; + const api = useApi(); + const host = useHost(); + const putBot = useApp((state) => state.putBot); + const putCrew = useApp((state) => state.putCrew); + const chief = isChief(bot, crew); + const [open, setOpen] = useState(null); + const close = () => setOpen(null); + + const items: MenuItem[] = [ + { label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") }, + { + label: chief ? words.stopChief : words.makeChief, + icon: Crown, + onSelect: () => + attempt(words.failed.chief, async () => + putCrew( + await api.call("crews.setLead", { crewId: crew.id, botId: chief ? null : bot.id }), + ), + ), + }, + { + label: words.restartFresh, + icon: RefreshCcw, + disabled: bot.paused || crew.paused, + onSelect: () => setOpen("fresh"), + }, + { + label: words.openFolder, + icon: FolderOpen, + onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), + }, + { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + ]; + + const dialogs = + open === "edit" ? ( + + ) : open === "fresh" ? ( + + attempt(words.failed.restart, async () => + putBot(await api.call("bots.restart", { botId: bot.id, fresh: true })), + ) + } + > + {words.fresh.body(bot.name)} + + ) : open === "archive" ? ( + + attempt(words.failed.archive, async () => + putBot(await api.call("bots.archive", { botId: bot.id })), + ) + } + > + {words.archiveConfirm.body} + + ) : null; + + return { items, dialogs }; +} From 6f4958c629be78981c8de44752856cef0b8cc958 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:47:54 -0300 Subject: [PATCH 5/6] feat(app): right-click a bot in the sidebar for its menu The menu opens at the pointer (or from the keyboard's menu key), without opening the bot, with the same actions as the header's menu. ContextMenu reuses the rows of Menu, stays inside the window and walks with the arrow keys; Menu gets the arrow keys too. --- app/src/features/crews/Sidebar.tsx | 25 ++++- app/src/features/crews/botMenu.test.tsx | 125 ++++++++++++++++++++++++ app/src/i18n/en/bots.ts | 1 + app/src/i18n/es/bots.ts | 1 + app/src/i18n/pt-BR/bots.ts | 1 + app/src/ui/ContextMenu.tsx | 107 ++++++++++++++++++++ app/src/ui/Menu.tsx | 78 ++++++++++----- docs/spec.md | 2 +- 8 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 app/src/features/crews/botMenu.test.tsx create mode 100644 app/src/ui/ContextMenu.tsx diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 73883475..3dd85325 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,5 +1,6 @@ import { Pause, Plus } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; +import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; import { type Messages, useT } from "../../i18n"; import { when } from "../../lib/format"; @@ -7,10 +8,12 @@ import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; import { AccountArea } from "../account/AccountArea"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; +import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; @@ -85,6 +88,10 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const selected = useApp((state) => state.selectedBotId === bot.id); const selectBot = useApp((state) => state.selectBot); + // A right-click opens the bot's menu, the same as in its header. + const actions = useBotActions(bot, crew); + const [menuAt, setMenuAt] = useState(null); + const closeMenu = useCallback(() => setMenuAt(null), []); const activity = bot.lastActivity; return (
  • APP_OPENED ? "animate-rise" : undefined}> @@ -93,7 +100,11 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { aria-current={selected ? "page" : undefined} aria-label={`${bot.name}, ${stateView(bot, crew.paused).label}`} onClick={() => selectBot(bot.id)} - className={`${row} gap-2.5 px-2.5 py-2 ${selected ? "bg-sunken" : ""}`} + onContextMenu={(event) => { + event.preventDefault(); + setMenuAt(menuPoint(event)); + }} + className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`} > @@ -119,6 +130,16 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { + {menuAt && ( + + )} + {/* Outside the list: a dialog is not part of the navigation. */} + {actions.dialogs && createPortal(actions.dialogs, document.body)}
  • ); } diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx new file mode 100644 index 00000000..f26c0988 --- /dev/null +++ b/app/src/features/crews/botMenu.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + const { host } = renderApp(fake); + await crewOpened("Ops"); + return { fake, host, crew, scout, writer }; +} + +const row = (name: string) => within(sidebar()).getByRole("button", { name: new RegExp(name) }); + +/** Right-clicks the bot's conversation and returns the menu that opens. */ +function rightClick(name: string) { + fireEvent.contextMenu(row(name), { clientX: 120, clientY: 200 }); + return screen.getByRole("menu", { name: `Actions for ${name}` }); +} + +const labels = (menu: HTMLElement) => + within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent); + +describe("a bot's right-click menu", () => { + test("offers what the header's menu offers, without opening the bot", async () => { + await twoBots(); + const menu = rightClick("Scout"); + const offered = labels(menu); + expect(offered).toEqual([ + "Edit", + "Make crew chief", + "Restart with a new conversation", + "Open folder", + "Archive bot", + ]); + // The crew's page is still the one open. + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(menu.style.left).toBe("120px"); + expect(menu.style.top).toBe("200px"); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + openBot("Scout"); + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + expect(labels(screen.getByRole("menu"))).toEqual(offered); + }); + + test("acts on the bot that was clicked, not the one that is open", async () => { + const { fake, host, crew, scout, writer } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Open folder" })); + expect(host.opened).toEqual([scout.workspace]); + expect(screen.queryByRole("menu")).toBeNull(); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Make crew chief" })); + await waitFor(() => expect(fake.crews.get(crew.id)?.leadBotId).toBe(scout.id)); + expect(labels(rightClick("Scout"))).toContain("Stop being chief"); + expect(labels(rightClick("Writer"))).toContain("Make crew chief"); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Archive bot" })); + const dialog = screen.getByRole("dialog", { name: "Archive Scout?" }); + expect(sidebar().contains(dialog)).toBe(false); + fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "bots.archive", params: { botId: scout.id } }); + // Writer stays open through it all. + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + expect(fake.bots.get(writer.id)?.archivedAt).toBeNull(); + }); + + test("edits from the menu and keeps a paused bot from restarting", async () => { + const { fake, scout } = await twoBots(); + await fake.call("bots.setPaused", { botId: scout.id, paused: true }); + const menu = rightClick("Scout"); + const fresh = within(menu).getByRole("menuitem", { name: "Restart with a new conversation" }); + expect((fresh as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Edit" })); + expect(screen.getByRole("dialog", { name: "Edit Scout" })).toBeDefined(); + }); + + test("opens from the keyboard and walks with the arrows", async () => { + await twoBots(); + const scout = row("Scout"); + scout.focus(); + // The menu key sends a context menu event without a pointer. + fireEvent.contextMenu(scout); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + const items = within(menu).getAllByRole("menuitem"); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(items.at(-1)); + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(items[0]); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(document.activeElement).toBe(scout); + }); + + test("closes on a click elsewhere, and another bot's menu takes its place", async () => { + await twoBots(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(screen.queryByRole("menu")).toBeNull(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(rightClick("Writer")).toBeDefined(); + expect(screen.getAllByRole("menu")).toHaveLength(1); + }); +}); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 14d5b269..47896821 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -41,6 +41,7 @@ export const bots = { showDetails: "Show details", hideDetails: "Hide details", more: "More bot actions", + menuOf: (name: string) => `Actions for ${name}`, edit: "Edit", restartFresh: "Restart with a new conversation", openFolder: "Open folder", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index b4dcfab9..ef9fa0e4 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -37,6 +37,7 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalles", hideDetails: "Ocultar detalles", more: "Más acciones del bot", + menuOf: (name: string) => `Acciones de ${name}`, edit: "Editar", restartFresh: "Reiniciar con una conversación nueva", openFolder: "Abrir carpeta", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 9cad8fc3..add69c70 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -37,6 +37,7 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalhes", hideDetails: "Ocultar detalhes", more: "Mais ações do bot", + menuOf: (name: string) => `Ações de ${name}`, edit: "Editar", restartFresh: "Reiniciar com uma nova conversa", openFolder: "Abrir pasta", diff --git a/app/src/ui/ContextMenu.tsx b/app/src/ui/ContextMenu.tsx new file mode 100644 index 00000000..c4f5801f --- /dev/null +++ b/app/src/ui/ContextMenu.tsx @@ -0,0 +1,107 @@ +import { type MouseEvent, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { type MenuItem, MenuItems, menuKeys } from "./Menu"; +import { POPOVER } from "./surface"; +import { useDismiss } from "./useDismiss"; + +/** A place in the window, in CSS pixels. */ +export interface Point { + x: number; + y: number; +} + +/** How close to the window's edge a menu may get. */ +const EDGE = 8; + +/** + * Where a `contextmenu` event asks for its menu: at the pointer, or, from + * the keyboard's menu key, which has none, just inside the element. + */ +export function menuPoint(event: MouseEvent): Point { + if (event.clientX !== 0 || event.clientY !== 0) { + return { x: event.clientX, y: event.clientY }; + } + const box = event.currentTarget.getBoundingClientRect(); + return { x: box.left + 24, y: box.top + box.height / 2 }; +} + +/** + * Window pixels, which the pointer is in, per pixel of the page. 1 in the + * app, whose webview does the zoom; the preview zooms the page with CSS. + */ +function pageScale(): number { + const page = document.documentElement; + return (page.offsetWidth && page.getBoundingClientRect().width / page.offsetWidth) || 1; +} + +/** + * The menu of a right-click: the rows of `Menu`, opened at `at` and kept + * inside the window. Closes on a pick, Escape, a click elsewhere, or when + * what is under it moves. + */ +export function ContextMenu({ + label, + items, + at, + onClose, +}: { + label: string; + items: MenuItem[]; + at: Point; + onClose(): void; +}) { + const root = useRef(null); + // Measured and put in place before it is first painted. + const [place, setPlace] = useState(at); + useDismiss(true, root, onClose); + + useLayoutEffect(() => { + const menu = root.current; + if (!menu) { + return; + } + // Its own size, not its box: it is still growing into place. + const scale = pageScale(); + const width = menu.offsetWidth * scale; + const height = menu.offsetHeight * scale; + setPlace({ + x: Math.max(EDGE, Math.min(at.x, window.innerWidth - width - EDGE)) / scale, + y: Math.max(EDGE, Math.min(at.y, window.innerHeight - height - EDGE)) / scale, + }); + // The keyboard follows the menu, and goes back where it was after it. + const before = document.activeElement; + menu.querySelector('[role^="menuitem"]:not(:disabled)')?.focus(); + return () => { + if (menu.contains(document.activeElement) && before instanceof HTMLElement) { + before.focus(); + } + }; + }, [at]); + + useLayoutEffect(() => { + window.addEventListener("resize", onClose); + window.addEventListener("blur", onClose); + window.addEventListener("scroll", onClose, true); + return () => { + window.removeEventListener("resize", onClose); + window.removeEventListener("blur", onClose); + window.removeEventListener("scroll", onClose, true); + }; + }, [onClose]); + + // In the body: no list that scrolls or moves can clip or carry it. + return createPortal( +
    event.preventDefault()} + style={{ left: place.x, top: place.y }} + className={`fixed min-w-60 origin-top-left ${POPOVER}`} + > + +
    , + document.body, + ); +} diff --git a/app/src/ui/Menu.tsx b/app/src/ui/Menu.tsx index 8341378f..5e22fc03 100644 --- a/app/src/ui/Menu.tsx +++ b/app/src/ui/Menu.tsx @@ -1,5 +1,5 @@ import { Check, type LucideIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useRef, useState } from "react"; import { Button } from "./Button"; import { POPOVER, POPOVER_ITEM } from "./surface"; import { useDismiss } from "./useDismiss"; @@ -14,6 +14,59 @@ export interface MenuItem { onSelect(): void; } +/** The arrow keys, Home and End walk the items of the menu they are pressed in. */ +export function menuKeys(event: KeyboardEvent): void { + const items = [ + ...event.currentTarget.querySelectorAll('[role^="menuitem"]:not(:disabled)'), + ]; + const last = items.length - 1; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number; + switch (event.key) { + case "ArrowDown": + next = at >= last ? 0 : at + 1; + break; + case "ArrowUp": + next = at <= 0 ? last : at - 1; + break; + case "Home": + next = 0; + break; + case "End": + next = last; + break; + default: + return; + } + event.preventDefault(); + items[next]?.focus(); +} + +/** The rows of a menu. `onPick` runs first: it closes the menu. */ +export function MenuItems({ items, onPick }: { items: MenuItem[]; onPick(): void }) { + return items.map(({ label, icon: Icon, danger, disabled, checked, onSelect }) => ( + + )); +} + /** A button that opens a short list of actions. */ export function Menu({ label, @@ -46,29 +99,10 @@ export function Menu({ {open && (
    - {items.map(({ label: itemLabel, icon: Icon, danger, disabled, checked, onSelect }) => ( - - ))} + setOpen(false)} />
    )} diff --git a/docs/spec.md b/docs/spec.md index 062638e2..ba5c2dd0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -620,7 +620,7 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar), 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. 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 6/6] 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.