Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion app/src/lib/fakeBots.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -89,5 +91,18 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
}
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;
},
};
}
14 changes: 13 additions & 1 deletion app/src/lib/fakeCrews.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -81,5 +82,16 @@ export function crewHandlers(fake: FakeBotloft): Pick<Handlers, CrewMethods> {
}
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 };
},
};
}
49 changes: 49 additions & 0 deletions app/src/lib/fakeDelete.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string>();
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;
}
}
}
16 changes: 16 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -992,13 +1004,15 @@ 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<Bot> };
"bots.create": { params: BotsCreateParams; result: Bot };
"bots.update": { params: BotsUpdateParams; result: Bot };
"bots.setPaused": { params: BotsSetPausedParams; result: Bot };
"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<ChatItem> };
"approvals.answer": { params: ApprovalsAnswerParams; result: Approval };
Expand Down Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions app/src/store/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
48 changes: 48 additions & 0 deletions app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -149,12 +154,55 @@ function withBot(state: AppState, bot: Bot): Partial<AppState> {
return { bots, selectedBotId: state.selectedBotId === bot.id ? null : state.selectedBotId };
}

/** Without the entries of `record` that fail `keep`. */
function kept<T>(record: Record<string, T>, keep: (value: T) => boolean): Record<string, T> {
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<AppState> {
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<AppState> {
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<AppState> | 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) {
Expand Down
13 changes: 13 additions & 0 deletions crates/botloft-core/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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(),
}
}
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ fn export_bindings() {
out.decl::<BotIdParams>();
out.decl::<BotsRestartParams>();
out.decl::<BotStateChanged>();
out.decl::<BotDeleted>();
out.decl::<CrewDeleted>();
out.decl::<SenderKind>();
out.decl::<MessageKind>();
out.decl::<DeliveryState>();
Expand Down Expand Up @@ -223,6 +225,13 @@ fn export_bindings() {
&crew,
);
out.method(method::CREWS_ARCHIVE, &out.name::<CrewIdParams>(), &crew);
let crew_deleted = out.name::<CrewDeleted>();
let bot_deleted = out.name::<BotDeleted>();
out.method(
method::CREWS_DELETE,
&out.name::<CrewIdParams>(),
&crew_deleted,
);
out.method(method::BOTS_LIST, &out.name::<BotsListParams>(), &bots);
out.method(method::BOTS_CREATE, &out.name::<BotsCreateParams>(), &bot);
out.method(method::BOTS_UPDATE, &out.name::<BotsUpdateParams>(), &bot);
Expand All @@ -242,6 +251,11 @@ fn export_bindings() {
&bot,
);
out.method(method::BOTS_ARCHIVE, &out.name::<BotIdParams>(), &bot);
out.method(
method::BOTS_DELETE,
&out.name::<BotIdParams>(),
&bot_deleted,
);
out.method(method::BOTS_RESTART, &out.name::<BotsRestartParams>(), &bot);
out.method(
method::CHAT_HISTORY,
Expand Down Expand Up @@ -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::<BotStateChanged>();
let item = out.name::<ChatItemChanged>();
let delta = out.name::<ChatDelta>();
Expand Down
Loading
Loading