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/envelope.rs b/crates/botloft-core/src/envelope.rs index 9e4c5249..09f5971a 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -10,6 +10,9 @@ pub enum Sender<'a> { Bot { handle: &'a str, }, + /// A bot the owner deleted after it sent the message (spec 7.6): there + /// is nobody to reply to. + DeletedBot, /// The daemon, e.g. to say a task expired. Botloft, } @@ -62,6 +65,7 @@ impl Envelope<'_> { // No `@`: a bot may well be called "Owner". Sender::Owner => "the owner".to_owned(), Sender::Bot { handle } => format!("@{handle}"), + Sender::DeletedBot => "a deleted bot".to_owned(), Sender::Botloft => "Botloft".to_owned(), } } @@ -168,6 +172,15 @@ mod tests { ); } + #[test] + fn a_deleted_sender_cannot_be_answered() { + let text = envelope(Sender::DeletedBot, MessageKind::Note, None).render(0); + assert_eq!( + text, + "[botloft] from a deleted bot · crew Site\n\nCheck the build.\nThanks!" + ); + } + #[test] fn results_and_notices_carry_the_task_status() { let done = task(0, TaskStatus::Done); diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 7e5617a0..c7a302a7 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -125,6 +125,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -223,6 +225,13 @@ fn export_bindings() { &crew, ); out.method(method::CREWS_ARCHIVE, &out.name::(), &crew); + let crew_deleted = out.name::(); + let bot_deleted = out.name::(); + out.method( + method::CREWS_DELETE, + &out.name::(), + &crew_deleted, + ); out.method(method::BOTS_LIST, &out.name::(), &bots); out.method(method::BOTS_CREATE, &out.name::(), &bot); out.method(method::BOTS_UPDATE, &out.name::(), &bot); @@ -242,6 +251,11 @@ fn export_bindings() { &bot, ); out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); + out.method( + method::BOTS_DELETE, + &out.name::(), + &bot_deleted, + ); out.method(method::BOTS_RESTART, &out.name::(), &bot); out.method( method::CHAT_HISTORY, @@ -304,6 +318,16 @@ fn export_bindings() { out.text.push_str("export interface RpcNotifications {\n"); let _ = writeln!(out.text, " \"{}\": {crew};", notification::CREW_CHANGED); let _ = writeln!(out.text, " \"{}\": {bot};", notification::BOT_CHANGED); + let _ = writeln!( + out.text, + " \"{}\": {crew_deleted};", + notification::CREW_DELETED + ); + let _ = writeln!( + out.text, + " \"{}\": {bot_deleted};", + notification::BOT_DELETED + ); let state = out.name::(); let item = out.name::(); let delta = out.name::(); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 43054c83..60b971bd 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -89,6 +89,7 @@ pub mod method { pub const CREWS_SET_WORK_FOLDER: &str = "crews.setWorkFolder"; pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; + pub const CREWS_DELETE: &str = "crews.delete"; pub const BOTS_LIST: &str = "bots.list"; pub const BOTS_CREATE: &str = "bots.create"; pub const BOTS_UPDATE: &str = "bots.update"; @@ -96,6 +97,7 @@ pub mod method { pub const BOTS_SET_PERMISSION_MODE: &str = "bots.setPermissionMode"; pub const BOTS_SET_MODEL: &str = "bots.setModel"; pub const BOTS_ARCHIVE: &str = "bots.archive"; + pub const BOTS_DELETE: &str = "bots.delete"; pub const BOTS_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; pub const APPROVALS_ANSWER: &str = "approvals.answer"; @@ -128,7 +130,9 @@ pub mod method { /// Server notification method names. pub mod notification { pub const CREW_CHANGED: &str = "crew.changed"; + pub const CREW_DELETED: &str = "crew.deleted"; pub const BOT_CHANGED: &str = "bot.changed"; + pub const BOT_DELETED: &str = "bot.deleted"; pub const BOT_STATE: &str = "bot.state"; pub const CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 7d890865..169ca45f 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -160,3 +160,22 @@ pub struct BotStateChanged { pub state: BotState, pub generation: Option, } + +/// A bot that was deleted for good (spec 7.6): the result of `bots.delete` +/// and the params of the `bot.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotDeleted { + pub bot_id: BotId, + pub crew_id: CrewId, +} + +/// A crew that was deleted with every bot in it (spec 7.6): the result of +/// `crews.delete` and the params of the `crew.deleted` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewDeleted { + pub crew_id: CrewId, +} diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs index 72574322..8a5c1568 100644 --- a/crates/botloft-store/src/approvals.rs +++ b/crates/botloft-store/src/approvals.rs @@ -95,6 +95,15 @@ impl Store { .optional()?) } + /// The bot's requests that still wait for the owner. + pub fn pending_approvals(&self, bot: &BotId) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT id FROM approvals WHERE bot_id = ?1 AND status = 'pending'")?; + let rows = stmt.query_map([bot.as_str()], |row| parse_column(row, 0))?; + Ok(rows.collect::>()?) + } + /// Expires every pending approval, of one bot or of all: their process /// is gone, so nobody waits for the answer. pub fn expire_approvals(&self, bot: Option<&BotId>, now: i64) -> Result> { @@ -204,6 +213,10 @@ mod tests { let fx = Fixture::new(); let first = pending(&fx, 0); let other = pending(&fx, 1); + assert_eq!( + fx.store.pending_approvals(&fx.bots[0].id).expect("pending"), + vec![first.approval.id.clone()] + ); let expired = fx .store .expire_approvals(Some(&fx.bots[0].id), 40) diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs new file mode 100644 index 00000000..1d586340 --- /dev/null +++ b/crates/botloft-store/src/delete.rs @@ -0,0 +1,77 @@ +//! Deleting a bot or a crew for good (spec 7.6 and 12). Archiving keeps the +//! rows; this removes them with everything that points at them, in one +//! transaction. + +use botloft_core::ids::{BotId, CrewId}; +use rusqlite::Connection; + +use crate::{Result, Store}; + +/// Every statement takes the bot's id. The order follows the foreign keys: +/// what points at a row goes, or lets go of it, before the row. +const DELETE_BOT: &[&str] = &[ + "DELETE FROM approvals WHERE bot_id = ?1", + "DELETE FROM chat_items WHERE bot_id = ?1", + "DELETE FROM browser_sites WHERE bot_id = ?1", + "DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + // What the bot received goes with it. + "UPDATE routine_runs SET message_id = NULL \ + WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM attachments WHERE message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM deliveries WHERE bot_id = ?1 \ + OR message_id IN (SELECT id FROM messages WHERE to_bot_id = ?1)", + "DELETE FROM messages WHERE to_bot_id = ?1", + "UPDATE messages SET routine_id = NULL \ + WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", + "DELETE FROM routines WHERE bot_id = ?1", + // What it sent stays with who received it, without a sender. + "UPDATE messages SET from_bot_id = NULL WHERE from_bot_id = ?1", + "UPDATE messages SET task_id = NULL WHERE task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "UPDATE tasks SET origin_task_id = NULL WHERE origin_task_id IN \ + (SELECT id FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1)", + "DELETE FROM tasks WHERE requester_bot_id = ?1 OR assignee_bot_id = ?1", + "UPDATE crews SET lead_bot_id = NULL WHERE lead_bot_id = ?1", +]; + +fn delete_bot_in(conn: &Connection, bot: &str) -> Result { + for statement in DELETE_BOT { + conn.execute(statement, [bot])?; + } + Ok(conn.execute("DELETE FROM bots WHERE id = ?1", [bot])? > 0) +} + +impl Store { + /// Deletes the bot with its chat, its approvals, the messages it + /// received, its routines, its browser sites and the tasks it asked for + /// or was given. Messages it sent to other bots stay, without a sender. + /// `false`, with nothing changed, if there is no such bot. + pub fn delete_bot(&self, id: &BotId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let deleted = delete_bot_in(&tx, id.as_str())?; + tx.commit()?; + Ok(deleted) + } + + /// Deletes the crew and every bot in it, archived ones too, as + /// [`Store::delete_bot`] does. `false` if there is no such crew. + pub fn delete_crew(&self, id: &CrewId) -> Result { + let tx = self.conn.unchecked_transaction()?; + let bots: Vec = { + let mut stmt = tx.prepare("SELECT id FROM bots WHERE crew_id = ?1")?; + let rows = stmt.query_map([id.as_str()], |row| row.get(0))?; + rows.collect::>()? + }; + for bot in &bots { + delete_bot_in(&tx, bot)?; + } + // Every message and task of a crew is to or for one of its bots, so + // nothing of the crew is left to hold it. + let deleted = tx.execute("DELETE FROM crews WHERE id = ?1", [id.as_str()])? > 0; + tx.commit()?; + Ok(deleted) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs new file mode 100644 index 00000000..8a4e53c4 --- /dev/null +++ b/crates/botloft-store/src/delete/tests.rs @@ -0,0 +1,304 @@ +//! Tests for deleting bots and crews. + +use botloft_core::ids::{ApprovalId, AttachmentId, ChatItemId, RoutineId, RoutineRunId, TaskId}; +use botloft_core::protocol::{ + Approval, ApprovalItem, ApprovalStatus, Attachment, ChatBody, ChatItem, Message, MessageKind, + Missed, Overlap, Routine, RoutineRun, RunStatus, Schedule, SenderKind, Task, TaskStatus, +}; + +use super::*; +use crate::tests::{Fixture, message_to}; +use crate::{ApprovalRecord, MessageFilter, TaskFilter}; + +/// A message from bot `from` to bot `to` of the fixture. +fn from_bot(fx: &Fixture, from: usize, to: usize, body: &str) -> Message { + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, body); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + fx.store + .insert_message(&message, &delivery, None) + .expect("message"); + message +} + +/// Bot `from` asks bot `to` for a task. +fn ask(fx: &Fixture, from: usize, to: usize, origin: Option<&Task>) -> Task { + let task = Task { + id: TaskId::generate(), + crew_id: fx.crew.id.clone(), + requester_bot_id: fx.bots[from].id.clone(), + assignee_bot_id: fx.bots[to].id.clone(), + status: TaskStatus::Open, + deadline_at: 1_000, + hops: 1, + origin_task_id: origin.map(|task| task.id.clone()), + result: None, + created_at: 0, + updated_at: 0, + }; + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[to].id, "do it"); + message.from_kind = SenderKind::Bot; + message.from_bot_id = Some(fx.bots[from].id.clone()); + message.kind = MessageKind::Task; + message.task_id = Some(task.id.clone()); + fx.store + .insert_message(&message, &delivery, Some(&task)) + .expect("task"); + task +} + +/// Everything a bot can own, for bot 0 (scout): a note from the owner with +/// a file, an approval, a browser site and a routine that ran once. +struct Owned { + attachment: AttachmentId, + approval: ApprovalId, + routine: RoutineId, +} + +fn fill(fx: &Fixture) -> Owned { + let scout = &fx.bots[0].id; + let (mut note, delivery) = message_to(&fx.crew.id, scout, "see attached"); + let attachment = AttachmentId::generate(); + note.attachments = vec![Attachment { + id: attachment.clone(), + name: "plan.pdf".into(), + media_type: "application/pdf".into(), + size: 2048, + path: "attachments/2026-09-30/plan.pdf".into(), + }]; + fx.store + .insert_message(¬e, &delivery, None) + .expect("note"); + + let approval = Approval { + id: ApprovalId::generate(), + bot_id: scout.clone(), + tool_name: "Bash".into(), + summary: "ls".into(), + input: r#"{"command":"ls"}"#.into(), + status: ApprovalStatus::Pending, + note: None, + created_at: 10, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: scout.clone(), + body: ChatBody::Approval(ApprovalItem { + approval_id: approval.id.clone(), + tool_name: approval.tool_name.clone(), + summary: approval.summary.clone(), + input: approval.input.clone(), + status: ApprovalStatus::Pending, + note: None, + }), + created_at: 10, + updated_at: 10, + }; + fx.store.insert_chat_item(&item).expect("item"); + let record = ApprovalRecord { + approval, + chat_item_id: item.id, + tool_use_id: "toolu_1".into(), + }; + fx.store.insert_approval(&record).expect("approval"); + fx.store + .allow_browser_site(scout, "example.com", 10) + .expect("site"); + + let routine = Routine { + id: RoutineId::generate(), + bot_id: scout.clone(), + name: "Morning".into(), + prompt: "Summarize".into(), + schedule: Schedule::Interval { minutes: 60 }, + timezone: "UTC".into(), + overlap: Overlap::Skip, + missed: Missed::RunOnce, + enabled: true, + next_run_at: Some(100), + last_run: None, + created_at: 0, + updated_at: 0, + archived_at: None, + }; + fx.store.insert_routine(&routine).expect("routine"); + let (mut fired, delivery) = message_to(&fx.crew.id, scout, "Summarize"); + fired.from_kind = SenderKind::System; + fired.routine_id = Some(routine.id.clone()); + let run = RoutineRun { + id: RoutineRunId::generate(), + routine_id: routine.id.clone(), + scheduled_for: 100, + status: RunStatus::Queued, + reason: None, + skipped_count: 0, + message_id: Some(fired.id.clone()), + created_at: 100, + finished_at: None, + }; + fx.store.fire_run(&run, &fired, &delivery).expect("run"); + Owned { + attachment, + approval: record.approval.id, + routine: routine.id, + } +} + +fn count(store: &Store, table: &str) -> i64 { + store + .conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count") +} + +/// Rows that point at something that is gone. +fn dangling(store: &Store) -> usize { + let mut stmt = store + .conn + .prepare("PRAGMA foreign_key_check") + .expect("check"); + stmt.query_map([], |_| Ok(())).expect("rows").count() +} + +#[test] +fn a_deleted_bot_takes_what_was_its_own() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let owned = fill(&fx); + let mut crew = fx.crew.clone(); + crew.lead_bot_id = Some(scout.clone()); + fx.store.update_crew(&crew).expect("chief"); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + assert_eq!(fx.store.bot(&scout).expect("bot"), None); + assert!(fx.store.bot(&writer).expect("bot").is_some()); + assert!( + fx.store + .chat_history(&scout, None, 50) + .expect("chat") + .is_empty() + ); + assert!( + fx.store + .deliveries(None, Some(&scout)) + .expect("deliveries") + .is_empty() + ); + assert_eq!(fx.store.attachment(&owned.attachment).expect("file"), None); + assert_eq!(fx.store.approval(&owned.approval).expect("approval"), None); + assert_eq!(fx.store.routine(&owned.routine).expect("routine"), None); + assert!( + fx.store + .runs(&owned.routine, None, 10) + .expect("runs") + .is_empty() + ); + assert!(fx.store.browser_sites(&scout).expect("sites").is_empty()); + let crew = fx.store.crew(&fx.crew.id).expect("crew").expect("kept"); + assert_eq!(crew.lead_bot_id, None); + assert_eq!(dangling(&fx.store), 0); + + assert!(!fx.store.delete_bot(&scout).expect("again"), "already gone"); +} + +#[test] +fn what_a_deleted_bot_sent_stays_without_a_sender() { + let fx = Fixture::new(); + let (scout, writer) = (fx.bots[0].id.clone(), fx.bots[1].id.clone()); + let note = from_bot(&fx, 0, 1, "the draft is in shared/"); + let reply = from_bot(&fx, 1, 0, "thanks"); + let asked = ask(&fx, 0, 1, None); + let given = ask(&fx, 1, 0, None); + + assert!(fx.store.delete_bot(&scout).expect("delete")); + + let kept = fx.store.message(¬e.id).expect("read").expect("kept"); + assert_eq!(kept.from_kind, SenderKind::Bot); + assert_eq!(kept.from_bot_id, None); + assert_eq!(fx.store.message(&reply.id).expect("read"), None); + // The writer's chat still shows what it received. + let chat = fx.store.chat_history(&writer, None, 50).expect("chat"); + assert_eq!(chat.len(), 2); + + // Tasks to and from the deleted bot go; the request the writer got + // stays as a message, without its task. + assert_eq!(fx.store.task(&asked.id).expect("task"), None); + assert_eq!(fx.store.task(&given.id).expect("task"), None); + let request = fx + .store + .messages(MessageFilter { + bot: Some(&writer), + limit: 10, + ..MessageFilter::default() + }) + .expect("messages"); + assert_eq!(request.len(), 2); + assert!(request.iter().all(|message| message.task_id.is_none())); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_chain_of_tasks_loses_only_the_deleted_link() { + let mut fx = Fixture::new(); + let mut editor = fx.bots[1].clone(); + editor.id = botloft_core::ids::BotId::generate(); + editor.handle = "editor".into(); + editor.slug = "editor".into(); + fx.store.insert_bot(&editor).expect("third bot"); + fx.bots.push(editor); + + // Scout asks the writer, who asks the editor. + let first = ask(&fx, 0, 1, None); + let second = ask(&fx, 1, 2, Some(&first)); + + assert!(fx.store.delete_bot(&fx.bots[0].id).expect("delete")); + + let left = fx.store.tasks(TaskFilter::default()).expect("tasks"); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, second.id); + assert_eq!(left[0].origin_task_id, None); + assert_eq!(dangling(&fx.store), 0); +} + +#[test] +fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() { + let fx = Fixture::new(); + fill(&fx); + from_bot(&fx, 0, 1, "hello"); + ask(&fx, 1, 0, None); + // An archived bot of the crew goes too. + let mut archived = fx.bots[1].clone(); + archived.archived_at = Some(5); + fx.store.update_bot(&archived).expect("archive"); + + let other = Fixture::new().crew; + let other = botloft_core::protocol::Crew { + slug: "other".into(), + ..other + }; + fx.store.insert_crew(&other).expect("other crew"); + + assert!(fx.store.delete_crew(&fx.crew.id).expect("delete")); + + assert_eq!(fx.store.crews(true).expect("crews"), vec![other]); + for table in [ + "bots", + "messages", + "deliveries", + "attachments", + "chat_items", + "approvals", + "tasks", + "routines", + "routine_runs", + "browser_sites", + ] { + assert_eq!(count(&fx.store, table), 0, "{table}"); + } + assert_eq!(dangling(&fx.store), 0); + assert!(!fx.store.delete_crew(&fx.crew.id).expect("again")); +} diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index ff856452..65238c4b 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -9,6 +9,7 @@ mod bots; mod browser_sites; mod chat; mod crews; +mod delete; mod deliveries; mod messages; mod migrate; diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index bc0e4411..bec38887 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -57,6 +57,14 @@ impl Approvals { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// Lets go of requests nobody will answer: their bot was deleted. + pub(crate) fn forget(&self, requests: &[ApprovalId]) { + let mut waiting = self.lock(); + for request in requests { + waiting.remove(request); + } + } } /// Puts a request in the bot's chat and waits for the owner. `None` if it diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index 6da1efeb..2283adb7 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -39,14 +39,14 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend let text = match message.from_kind { // The owner is the session's user: their words go as written. SenderKind::Owner => with_attachment_list(&message.body, &message.attachments), - SenderKind::Bot => envelope( - message, - context, - Sender::Bot { - handle: context.sender_handle.unwrap_or("unknown"), - }, - now, - ), + SenderKind::Bot => { + let from = match context.sender_handle { + Some(handle) => Sender::Bot { handle }, + // Deleted while the message waited (spec 7.6). + None => Sender::DeletedBot, + }; + envelope(message, context, from, now) + } SenderKind::System => match context.routine { Some((name, scheduled, timezone)) => RoutineEnvelope { name, diff --git a/crates/botloftd/src/routines/mod.rs b/crates/botloftd/src/routines/mod.rs index bce735bc..640a20b2 100644 --- a/crates/botloftd/src/routines/mod.rs +++ b/crates/botloftd/src/routines/mod.rs @@ -43,6 +43,11 @@ impl Routines { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + + /// The bot was deleted, and its runs with it. + pub(crate) fn forget(&self, bot: &BotId) { + self.turns().remove(bot); + } } /// Runs the scheduler until the daemon stops. The first pass also ends the diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 4f62a504..9d37c7e0 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,8 +8,8 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, - modes, routines, screens, settings, tasks, + self, ApiResult, attachments, bots, chat, crews, delete, deliveries, files, lead, messages, + models, modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -28,6 +28,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(crews::set_work_folder(daemon, parse(params)?)), method::CREWS_SET_LEAD => reply(lead::set_lead(daemon, parse(params)?)), method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), + method::CREWS_DELETE => reply(delete::crew(daemon, parse(params)?)), method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)), method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)), method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)), @@ -37,6 +38,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(models::set_model(daemon, parse(params)?)), method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)), + method::BOTS_DELETE => reply(delete::bot(daemon, parse(params)?)), method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index b5105ef1..8b935c2b 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -199,7 +199,9 @@ fn handle( fn to_notification(event: &Event) -> String { let (name, params) = match event { Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), + Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), + Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)), Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), diff --git a/crates/botloftd/src/screens/mod.rs b/crates/botloftd/src/screens/mod.rs index cc9804e0..0e49d406 100644 --- a/crates/botloftd/src/screens/mod.rs +++ b/crates/botloftd/src/screens/mod.rs @@ -79,6 +79,12 @@ impl Screens { .clone() } + /// The bot was deleted: its key stops working and its drafts go. + pub(crate) fn forget(&self, bot: &BotId) { + self.lock_keys().remove(bot); + self.drafts().remove(bot); + } + /// The bot a key belongs to. pub fn bot_of(&self, key: &str) -> Option { if key.is_empty() { diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs new file mode 100644 index 00000000..1efdad1e --- /dev/null +++ b/crates/botloftd/src/service/delete.rs @@ -0,0 +1,149 @@ +//! `bots.delete` and `crews.delete` (spec 7.6): removing a bot or a crew +//! for good, active or archived. Archiving keeps everything in the +//! database; deleting keeps only the folders on disk (spec 5). + +use botloft_core::ids::{BotId, MessageId}; +use botloft_core::protocol::{ + BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind, +}; +use botloft_store::{BotRecord, Store, TaskFilter}; +use tracing::{info, warn}; + +use super::messages::post; +use super::tasks::UNFINISHED; +use super::{ApiResult, bots, crews, lead}; +use crate::state::{Daemon, Event}; + +/// Deletes the bot: its process stops at once and its conversation, +/// routines and tasks leave the database. Its folder stays. +pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { + let store = daemon.store(); + let record = bots::find(&store, ¶ms.bot_id)?; + let crew = crews::find(&store, &record.crew_id)?; + let notices = task_notices(&store, &crew, &record)?; + let waiting = store.pending_approvals(&record.id)?; + store.delete_bot(&record.id)?; + stop(daemon, &record.id); + + if lead::is_lead(&crew, &record.id) { + // The crew lost its chief with the bot. + crews::changed(daemon, crews::find(&store, &crew.id)?); + } + let deleted = BotDeleted { + bot_id: record.id, + crew_id: crew.id.clone(), + }; + daemon.emit(Event::BotDeleted(deleted.clone())); + for (to, body) in notices { + tell(daemon, &store, &crew, to, body); + } + drop(store); + daemon.approvals.forget(&waiting); + release(daemon, &deleted.bot_id); + info!(bot = %deleted.bot_id, "bot deleted"); + Ok(deleted) +} + +/// Deletes the crew with every bot in it, archived ones too. Its folders +/// stay. +pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { + let store = daemon.store(); + let crew = crews::find(&store, ¶ms.crew_id)?; + let bots = store.bots(Some(&crew.id), true)?; + let mut waiting = Vec::new(); + for bot in &bots { + waiting.extend(store.pending_approvals(&bot.id)?); + } + store.delete_crew(&crew.id)?; + for bot in &bots { + stop(daemon, &bot.id); + } + let deleted = CrewDeleted { crew_id: crew.id }; + daemon.emit(Event::CrewDeleted(deleted.clone())); + drop(store); + daemon.approvals.forget(&waiting); + for bot in &bots { + release(daemon, &bot.id); + } + info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted"); + Ok(deleted) +} + +/// Kills the bot's process and closes its browser, profile included. +fn stop(daemon: &Daemon, bot: &BotId) { + daemon.supervisor.forget(bot); + daemon.browsers.forget(bot); +} + +/// Lets go of what the daemon kept in memory for the bot. Called without +/// the store: the routines take their own lock before it. +fn release(daemon: &Daemon, bot: &BotId) { + daemon.routines.forget(bot); + daemon.screens.forget(bot); +} + +/// What the other bots should hear about the unfinished tasks a deleted +/// bot leaves (spec 7.6): who to tell, and what. Only bots that still run +/// are told. +fn task_notices(store: &Store, crew: &Crew, bot: &BotRecord) -> ApiResult> { + if crew.archived_at.is_some() { + return Ok(Vec::new()); + } + let handle = &bot.handle; + let owed = store.tasks(TaskFilter { + assignee: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let asked = store.tasks(TaskFilter { + requester: Some(&bot.id), + statuses: &UNFINISHED, + ..TaskFilter::default() + })?; + let owed = owed.into_iter().rev().map(|task| { + let body = format!( + "Task {} for @{handle} will not be done: the owner deleted @{handle}. Get it done \ + another way, or tell the owner what is missing.", + task.id + ); + (task.requester_bot_id, body) + }); + let asked = asked.into_iter().rev().map(|task| { + let body = format!( + "Task {} from @{handle} no longer needs a result: the owner deleted @{handle}. \ + Stop working on it.", + task.id + ); + (task.assignee_bot_id, body) + }); + let mut notices = Vec::new(); + for (to, body) in owed.chain(asked) { + if store + .bot(&to)? + .is_some_and(|other| other.archived_at.is_none()) + { + notices.push((to, body)); + } + } + Ok(notices) +} + +/// A notice from Botloft to a bot of the crew. +fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { + let notice = Message { + id: MessageId::generate(), + crew_id: crew.id.clone(), + from_kind: SenderKind::System, + from_bot_id: None, + to_bot_id: to, + kind: MessageKind::System, + body, + task_id: None, + routine_id: None, + attachments: Vec::new(), + created_at: daemon.clock.now_ms(), + }; + if let Err(err) = post(daemon, store, notice, None) { + warn!(crew = %crew.id, "could not tell a bot about a deleted bot's task: {err}"); + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 7aba88e0..a3fc2343 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -5,6 +5,7 @@ pub mod attachments; pub mod bots; pub mod chat; pub mod crews; +pub mod delete; pub mod deliveries; pub mod files; pub mod lead; diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index 27355b3f..c1c18006 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -22,7 +22,7 @@ use crate::state::Daemon; /// Longest deadline a bot may set: a week. pub const MAX_DEADLINE_MINUTES: u32 = 7 * 24 * 60; /// Tasks that still expect a result. -const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; +pub(crate) const UNFINISHED: [TaskStatus; 2] = [TaskStatus::Open, TaskStatus::Expired]; #[derive(Debug, Clone)] pub struct TaskSettings { diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index f947f00a..73742cfc 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, Bot, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, - Crew, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, + ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -30,7 +30,9 @@ use crate::workspace::WorkspaceEnv; #[derive(Debug, Clone, PartialEq)] pub enum Event { CrewChanged(Crew), + CrewDeleted(CrewDeleted), BotChanged(Bot), + BotDeleted(BotDeleted), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), diff --git a/crates/botloftd/src/supervisor/forget.rs b/crates/botloftd/src/supervisor/forget.rs new file mode 100644 index 00000000..0b1c84e5 --- /dev/null +++ b/crates/botloftd/src/supervisor/forget.rs @@ -0,0 +1,36 @@ +//! A deleted bot leaves the supervisor (spec 7.6). + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tracing::warn; + +use super::{Inner, Supervisor}; + +impl Supervisor { + /// The bot was deleted: its process is killed at once, without waiting + /// for its turn, and nothing of it is kept. Whatever the process still + /// prints is ignored, and its exit finds no slot. + pub fn forget(&self, bot: &BotId) { + let mut inner = self.lock(); + let Inner { + slots, + tokens, + sessions, + gone, + .. + } = &mut *inner; + // A pass that read the bot just before must not start it again. + gone.insert(bot.clone()); + sessions.remove(bot); + let Some(slot) = slots.remove(bot) else { + return; + }; + self.count_busy(slot.state, BotState::Offline); + if let Some(running) = slot.running { + tokens.remove(&running.token_hash); + if let Err(err) = running.control.kill() { + warn!(bot = %bot, "could not stop a deleted bot: {err}"); + } + } + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 0a4f83c0..d7e33098 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -5,6 +5,7 @@ //! Lock order: the store lock may be held while taking the supervisor lock //! (service calls read states), never the other way around. +mod forget; mod reconcile; mod relaunch; mod settings; @@ -13,7 +14,7 @@ mod slot; mod spawn; mod turns; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::Duration; @@ -64,6 +65,8 @@ struct Inner { /// The conversation each bot resumes, as far as this run knows; the /// database has it too (spec 7.3). sessions: HashMap, + /// Bots that were deleted while this daemon ran (spec 7.6). + gone: HashSet, claude: ClaudeStatus, sign_in: sign_in::SignIn, } diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 947a459d..1e2ce5ee 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -70,6 +70,10 @@ impl Supervisor { bot: &BotRecord, now: Instant, ) { + // Deleted after this pass read the database. + if inner.gone.contains(&bot.id) { + return; + } let archived = bot.archived_at.is_some() || crew.archived_at.is_some(); if archived && !inner.slots.contains_key(&bot.id) { return; diff --git a/crates/botloftd/tests/delete.rs b/crates/botloftd/tests/delete.rs new file mode 100644 index 00000000..22edd63f --- /dev/null +++ b/crates/botloftd/tests/delete.rs @@ -0,0 +1,259 @@ +//! Deleting bots and crews for good (spec 7.6): what stops, what leaves +//! the database and what stays on disk. What the other bots hear is in +//! `delete_messages.rs`. + +mod common; + +use std::time::Duration; + +use botloft_core::ids::BotId; +use common::bots::{ready_bot, two_bots}; +use common::mcp::Mcp; +use common::stream; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +fn id_of(bot: &Value) -> BotId { + bot["id"].as_str().expect("id").parse().expect("bot id") +} + +#[tokio::test] +async fn a_deleted_bot_stops_and_leaves_only_its_folder() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let mut watcher = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (scout, process, _) = ready_bot(&t, &mut app, &crew, "Scout").await; + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "Hello" }), + ) + .await + .expect("send"); + process.wait_lines(1).await; + let workspace = t.paths.bot_workspace("ops", "scout"); + std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory"); + + let deleted = app + .call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete"); + assert_eq!( + deleted, + json!({ "botId": scout["id"], "crewId": crew["id"] }) + ); + assert_eq!(watcher.notification("bot.deleted").await, deleted); + assert!(process.killed(), "the process does not outlive the bot"); + assert_eq!(t.daemon.supervisor.status(&id_of(&scout)), None); + + assert_eq!( + app.call("bots.list", json!({})).await.expect("bots"), + json!([]) + ); + for (method, params) in [ + ("chat.history", json!({ "botId": scout["id"] })), + ("messages.list", json!({ "botId": scout["id"] })), + ("bots.delete", json!({ "botId": scout["id"] })), + ( + "messages.send", + json!({ "botId": scout["id"], "body": "Still there?" }), + ), + ] { + let gone = app.call(method, params).await.expect_err(method); + assert_eq!(gone.code, NOT_FOUND, "{method}"); + } + let timeline = app + .call("messages.list", json!({ "crewId": crew["id"] })) + .await + .expect("timeline"); + assert_eq!(timeline, json!([])); + + // The folder stays as it was, and nothing starts the bot again. + assert_eq!( + std::fs::read_to_string(workspace.join("CLAUDE.md")).expect("memory"), + "what the bot learned" + ); + t.daemon.supervisor.reconcile().await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(t.runtime.processes().len(), 1); +} + +#[tokio::test] +async fn deleting_the_chief_leaves_the_crew_without_one() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let lead = json!({ "name": "Chief", "role": "Leads", "instructions": "Ship the site." }); + let crew = app + .call("crews.create", json!({ "name": "Ops", "lead": lead })) + .await + .expect("crew"); + let mut watcher = t.session().await; + app.call("bots.delete", json!({ "botId": crew["leadBotId"] })) + .await + .expect("delete"); + let changed = watcher.notification("crew.changed").await; + assert_eq!(changed["leadBotId"], Value::Null); + let crews = app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews[0]["leadBotId"], Value::Null); +} + +#[tokio::test] +async fn archived_bots_and_crews_can_be_deleted_too() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let scout = app.call("bots.create", bot.clone()).await.expect("bot"); + app.call("bots.archive", json!({ "botId": scout["id"] })) + .await + .expect("archive"); + app.call("bots.delete", json!({ "botId": scout["id"] })) + .await + .expect("delete an archived bot"); + + // The folder is still there, so a new bot of the same name gets another. + let again = app.call("bots.create", bot).await.expect("bot"); + assert_eq!(again["handle"], "scout"); + assert_eq!(again["slug"], "scout-2"); + + app.call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("archive"); + app.call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect("delete an archived crew"); + let gone = app + .call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect_err("already gone"); + assert_eq!(gone.code, NOT_FOUND); +} + +#[tokio::test] +async fn a_deleted_crew_takes_its_bots_and_keeps_its_folders() { + let mut c = two_bots().await; + let mut watcher = c.t.session().await; + let other = c + .app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("other crew"); + let (editor, editor_process, _) = ready_bot(&c.t, &mut c.app, &other, "Editor").await; + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "Please help.", "kind": "task" }), + ) + .await + .expect("task"); + c.writer_process.wait_lines(1).await; + let lead = bot_named(&mut c.app, "Lead").await; + let shared = c.t.paths.shared_dir("ops"); + std::fs::write(shared.join("report.md"), "the work").expect("work file"); + + let deleted = c + .app + .call("crews.delete", json!({ "crewId": c.crew["id"] })) + .await + .expect("delete"); + assert_eq!(deleted, json!({ "crewId": c.crew["id"] })); + assert_eq!(watcher.notification("crew.deleted").await, deleted); + assert!(c.lead_process.killed() && c.writer_process.killed()); + assert!(!editor_process.killed(), "other crews keep running"); + assert_eq!(c.t.daemon.supervisor.status(&id_of(&lead)), None); + + let crews = c.app.call("crews.list", json!(null)).await.expect("crews"); + assert_eq!(crews, json!([other])); + let bots = c.app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(bots.as_array().expect("list").len(), 1); + assert_eq!(bots[0]["id"], editor["id"]); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + // A deleted bot's token opens nothing. + let refused = c.writer.request("ping", json!({})).await; + assert_eq!(refused.status, 401); + + assert_eq!( + std::fs::read_to_string(shared.join("report.md")).expect("work file"), + "the work" + ); + assert!(c.t.paths.bot_workspace("ops", "lead").is_dir()); +} + +#[tokio::test] +async fn a_request_waiting_for_the_owner_goes_with_the_bot() { + let mut c = two_bots().await; + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": "Build it" }), + ) + .await + .expect("send"); + let line = c.lead_process.wait_lines(1).await[0].clone(); + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::tool_use( + "toolu_1", + "Bash", + json!({ "command": "make" }), + )) + .await; + let mut asking = Mcp::new(c.lead.addr, c.lead.token.clone()); + let args = + json!({ "tool_name": "Bash", "input": { "command": "make" }, "tool_use_id": "toolu_1" }); + let decision = tokio::spawn(async move { + asking + .tool("permission_prompt", args) + .await + .expect("answer") + }); + let request = loop { + let item = c.app.notification("chat.item").await; + if item["item"]["body"]["kind"] == "approval" { + break item["item"]["body"]["approvalId"].clone(); + } + }; + + let started = std::time::Instant::now(); + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + let outcome = decision.await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); + assert!( + started.elapsed() < Duration::from_secs(2), + "the request did not wait for its 3 s timeout" + ); + let gone = c + .app + .call( + "approvals.answer", + json!({ "approvalId": request, "allow": true }), + ) + .await + .expect_err("the request went with the bot"); + assert_eq!(gone.code, NOT_FOUND); +} diff --git a/crates/botloftd/tests/delete_messages.rs b/crates/botloftd/tests/delete_messages.rs new file mode 100644 index 00000000..a8e92835 --- /dev/null +++ b/crates/botloftd/tests/delete_messages.rs @@ -0,0 +1,143 @@ +//! What the other bots hear when a bot is deleted (spec 7.6): notices +//! about the tasks it leaves, and its messages that still waited. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use common::Client; +use common::bots::{text_of, two_bots}; +use serde_json::{Value, json}; + +async fn bot_named(app: &mut Client, name: &str) -> Value { + let bots = app.call("bots.list", json!({})).await.expect("bots"); + bots.as_array() + .expect("list") + .iter() + .find(|bot| bot["name"] == name) + .cloned() + .unwrap_or_else(|| panic!("no bot named {name}")) +} + +#[tokio::test] +async fn the_other_bots_hear_about_the_tasks_a_deleted_bot_leaves() { + let mut c = two_bots().await; + let ask = |to: &str| json!({ "to": to, "body": "Please help.", "kind": "task" }); + let owed = c + .writer + .tool("send_message", ask("lead")) + .await + .expect("task for the lead"); + let asked = c + .lead + .tool("send_message", ask("writer")) + .await + .expect("task for the writer"); + let (owed, asked) = ( + owed["task_id"].as_str().expect("id").to_owned(), + asked["task_id"].as_str().expect("id").to_owned(), + ); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + c.writer_process.wait_lines(2).await; + + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + let lines = c.writer_process.wait_lines(4).await; + assert_eq!( + text_of(&lines[2]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {owed} for @lead will not be done: the \ + owner deleted @lead. Get it done another way, or tell the owner what is missing." + ) + ); + assert_eq!( + text_of(&lines[3]), + format!( + "[botloft] from Botloft · crew Ops\n\nTask {asked} from @lead no longer needs a \ + result: the owner deleted @lead. Stop working on it." + ) + ); + assert_eq!( + c.app.call("tasks.list", json!({})).await.expect("tasks"), + json!([]) + ); + let gone = c + .writer + .tool( + "complete_task", + json!({ "task_id": asked, "result": "Done anyway." }), + ) + .await + .expect_err("the task went with the bot"); + assert!(gone.contains("does not exist"), "{gone}"); + + // What the lead wrote stays in the writer's chat, without a sender. + let writer = bot_named(&mut c.app, "Writer").await; + let kept = c + .app + .call("messages.list", json!({ "botId": writer["id"] })) + .await + .expect("messages"); + let kept: Vec<_> = kept.as_array().expect("list").iter().rev().collect(); + assert_eq!(kept.len(), 4); + for message in &kept[..2] { + assert_eq!(message["fromKind"], "bot"); + assert_eq!(message["fromBotId"], Value::Null); + assert_eq!(message["taskId"], Value::Null); + } + assert_eq!(kept[1]["body"], "The draft is in shared/."); + assert_eq!(kept[2]["fromKind"], "system"); +} + +#[tokio::test] +async fn a_message_still_waiting_arrives_from_a_deleted_bot() { + let mut c = two_bots().await; + let writer = bot_named(&mut c.app, "Writer").await; + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": true }), + ) + .await + .expect("pause"); + c.lead + .tool( + "send_message", + json!({ "to": "writer", "body": "The draft is in shared/." }), + ) + .await + .expect("note"); + let lead = bot_named(&mut c.app, "Lead").await; + c.app + .call("bots.delete", json!({ "botId": lead["id"] })) + .await + .expect("delete"); + + c.app + .call( + "bots.setPaused", + json!({ "botId": writer["id"], "paused": false }), + ) + .await + .expect("resume"); + // The lead's, the writer's first and the one the writer resumes in. + let process = c.t.runtime.process(3).await; + c.t.until_state(&writer, BotState::Idle).await; + c.t.clock.advance(Duration::from_secs(5)); + let lines = process.wait_lines(1).await; + assert_eq!( + text_of(&lines[0]), + "[botloft] from a deleted bot · crew Ops\n\nThe draft is in shared/." + ); +} diff --git a/docs/spec.md b/docs/spec.md index 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.