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/3] 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/3] 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/3] 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.