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
2 changes: 2 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { FakeCrewAccess } from "./fakeCrewAccess";
import { crewHandlers } from "./fakeCrews";
import { FakeDesktop } from "./fakeDesktop";
import { FakeFiles } from "./fakeFiles";
import { islandHandlers } from "./fakeIsland";
import { FakeLesson } from "./fakeLesson";
import { FakeMcp } from "./fakeMcp";
import { mobileHandlers } from "./fakeMobile";
Expand Down Expand Up @@ -325,6 +326,7 @@ export class FakeBotloft implements BotloftApi {
...this.reactions.handlers(),
...this.lesson.handlers(),
...usageHandlers(this),
...islandHandlers(this),
...backupHandlers(this),
...cloudHandlers(this),
...autoBackupHandlers(this),
Expand Down
46 changes: 46 additions & 0 deletions app/src/lib/fakeIsland.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { FakeBotloft } from "./fake";

describe("the fake's island methods", () => {
it("lists what waits with the phone's rules, oldest first", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.chat.ask(scout.id, "Bash", "git status", JSON.stringify({ command: "git status" }));
fake.chat.ask(scout.id, "mcp__github__create_issue", "Fix it", "{}");
fake.chat.ask(scout.id, "Bash", "echo", `{"command": "echo ${"x".repeat(20)}…`);
fake.questions.ask(scout.id, "Coffee?", ["Yes", "No"]);

const cards = await fake.call("cards.list");
expect(
cards.approvals.map((card) => [card.text.slice(0, 10), card.cut, card.atComputer]),
).toEqual([
["git status", false, false],
["{}", false, true],
['{"command"', true, false],
]);
expect(cards.approvals[0]?.bot.name).toBe("Scout");
expect(cards.approvals[0]?.crew).toBe("Ops");
expect(cards.questions.map((card) => card.text)).toEqual(["Coffee?"]);
});

it("counts the edits and the time in turns since the start of the day", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.chat.tool(scout.id, "Edit", {
status: "done",
file: "C:\\work\\a.md",
lines: { added: 3, removed: 1 },
});
fake.chat.turn(scout.id);

const today = await fake.call("activity.today", { since: 0 });
expect(today.filesEdited).toBe(1);
expect(today.lines).toEqual({ added: 3, removed: 1 });
expect(today.busiest?.name).toBe("Scout");
const later = await fake.call("activity.today", { since: fake.now + 1 });
expect(later.filesEdited).toBe(0);
expect(later.busiest).toBeNull();
});
});
134 changes: 134 additions & 0 deletions app/src/lib/fakeIsland.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// The fake daemon's island methods (spec 31): the cards that wait, built
// from the fake chat and questions with the phone's rules (28.5), and what
// the crews did since a moment, counted like the daemon's `today.rs`.

import type { FakeBotloft, Handlers } from "./fake";
import type { ApprovalCard, BusyBot, QuestionCard } from "./protocol.gen";

/** The longest text a card shows whole (the daemon's `CARD_TEXT_MAX`). */
const CARD_TEXT_MAX = 8 * 1024;

/** The tools a card may allow; the rest is answered in the Botloft. */
const MAY_ALLOW = new Set([
"Bash",
"PowerShell",
"WebFetch",
"WebSearch",
"Read",
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"ExitPlanMode",
"mcp__botloft__show_plan",
]);

/** What to read before allowing, and whether it is not all of the request. */
function textOf(input: string): { text: string; cut: boolean } {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return { text: input.slice(0, CARD_TEXT_MAX), cut: true };
}
const fields = parsed as { command?: unknown; plan?: unknown };
const text =
typeof fields.command === "string"
? fields.command
: typeof fields.plan === "string"
? fields.plan
: input;
return text.length > CARD_TEXT_MAX
? { text: text.slice(0, CARD_TEXT_MAX), cut: true }
: { text, cut: false };
}

export function islandHandlers(fake: FakeBotloft): Pick<Handlers, "cards.list" | "activity.today"> {
const who = (botId: string) => {
const bot = fake.bot(botId, false);
return { bot: { name: bot.name, color: bot.color }, crew: fake.crew(bot.crewId, false).name };
};
return {
"cards.list": () => {
const approvals: ApprovalCard[] = [];
for (const item of fake.chat.items) {
if (item.body.kind !== "approval" || item.body.status !== "pending") {
continue;
}
approvals.push({
approvalId: item.body.approvalId,
...who(item.botId),
createdAt: item.createdAt,
toolName: item.body.toolName,
summary: item.body.summary,
...(item.body.explanation !== null && { explanation: item.body.explanation }),
...textOf(item.body.input),
atComputer: !MAY_ALLOW.has(item.body.toolName),
});
}
const questions: QuestionCard[] = fake.questions.asked
.map((entry) => entry.question)
.filter((question) => question.status === "open")
.map((question) => ({
questionId: question.id,
...who(question.botId),
createdAt: question.createdAt,
text: question.text,
options: question.options,
}));
const oldest = (a: { createdAt: number }, b: { createdAt: number }) =>
a.createdAt - b.createdAt;
return { approvals: approvals.sort(oldest), questions: questions.sort(oldest) };
},
"activity.today": ({ since }) => {
const tasks = [...fake.conversation.tasks.values()].filter((task) => task.updatedAt >= since);
const files = new Set<string>();
const lines = { added: 0, removed: 0 };
let requestsAnswered = 0;
const work = new Map<string, number>();
for (const item of fake.chat.items) {
const body = item.body;
if (
body.kind === "tool" &&
item.createdAt >= since &&
body.status === "done" &&
body.lines
) {
if (body.file) files.add(body.file);
lines.added += body.lines.added;
lines.removed += body.lines.removed;
}
if (body.kind === "approval" && item.updatedAt >= since) {
if (body.status === "allowed" || body.status === "denied") requestsAnswered += 1;
}
if (body.kind === "turn" && item.createdAt >= since) {
work.set(item.botId, (work.get(item.botId) ?? 0) + body.durationMs);
}
}
const [top] = [...work.entries()].filter(([, ms]) => ms > 0).sort((a, b) => b[1] - a[1]);
let busiest: BusyBot | null = null;
if (top) {
const bot = fake.bot(top[0], false);
busiest = {
botId: bot.id,
name: bot.name,
color: bot.color,
crew: fake.crew(bot.crewId, false).name,
workMs: top[1],
};
}
return {
tasksDone: tasks.filter((task) => task.status === "done").length,
tasksFailed: tasks.filter((task) => task.status === "failed" || task.status === "expired")
.length,
botMessages: fake.conversation.messages.filter(
(message) => message.fromKind === "bot" && message.createdAt >= since,
).length,
requestsAnswered,
filesEdited: files.size,
lines,
busiest,
};
},
};
}
57 changes: 57 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,61 @@ export type ToPhone = { "t": "snapshot", approvals: Array<ApprovalCard>, questio
*/
export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, } | { "t": "chats" } | { "t": "history", req: number, botId: BotId, before?: ChatItemId, } | { "t": "send", clientId: string, botId: BotId, text: string, } | { "t": "watch", botId?: BotId, };

/**
* Every request and question waiting for the owner, oldest first, as the
* phone sees them (spec 31.5, 28.5).
*/
export type Cards = { approvals: Array<ApprovalCard>, questions: Array<QuestionCard>, };

/**
* Params of `activity.today`.
*/
export type ActivityTodayParams = {
/**
* Where the day starts, in Unix ms: the app sends its local midnight.
*/
since: number, };

export type BusyBot = { botId: BotId, name: string, color: string, crew: string,
/**
* Time in turns since `since`, in ms.
*/
workMs: number, };

/**
* What every crew did since `since`, counted from the database without a
* bot's turn (spec 31.4).
*/
export type DayActivity = {
/**
* Tasks between bots that ended done.
*/
tasksDone: number,
/**
* Tasks that ended failed or expired.
*/
tasksFailed: number,
/**
* Messages one bot sent another.
*/
botMessages: number,
/**
* Requests the owner allowed or denied.
*/
requestsAnswered: number,
/**
* Distinct files that edits counted in `lines` changed.
*/
filesEdited: number,
/**
* Lines added and removed by the edits that finished well (spec 8.2).
*/
lines: EditLines,
/**
* The bot that spent the most time in turns; `None` on a quiet day.
*/
busiest: BusyBot | null, };

export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
Expand Down Expand Up @@ -2374,6 +2429,8 @@ export interface RpcMethods {
"mobile.pair_cancel": { params: MobilePairIdParams; result: null };
"mobile.pair_confirm": { params: MobilePairConfirmParams; result: null };
"mobile.revoke": { params: MobileRevokeParams; result: MobileStatus };
"cards.list": { params: undefined; result: Cards };
"activity.today": { params: ActivityTodayParams; result: DayActivity };
"routines.list": { params: RoutinesListParams; result: Array<Routine> };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
Expand Down
4 changes: 4 additions & 0 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ mod cloud;
mod crew_access;
#[path = "export_desktop.rs"]
mod desktop;
#[path = "export_island.rs"]
mod island;
#[path = "export_mcp.rs"]
mod mcp;
#[path = "export_mobile.rs"]
Expand Down Expand Up @@ -210,6 +212,7 @@ fn export_bindings() {
backup::decls(&mut out);
cloud::decls(&mut out);
mobile::decls(&mut out);
island::decls(&mut out);
out.decl::<ApprovalItem>();
usage::decls(&mut out);
questions::decls(&mut out);
Expand Down Expand Up @@ -411,6 +414,7 @@ fn export_bindings() {
backup::methods(&mut out);
cloud::methods(&mut out);
mobile::methods(&mut out);
island::methods(&mut out);
routines::methods(&mut out);
questions::methods(&mut out);
reactions::methods(&mut out);
Expand Down
21 changes: 21 additions & 0 deletions crates/botloft-core/src/protocol/export_island.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! The island part of the TypeScript bindings (spec 31).

use super::super::*;
use super::Out;

/// After the mobile cards, which `Cards` holds.
pub(super) fn decls(out: &mut Out) {
out.decl::<Cards>();
out.decl::<ActivityTodayParams>();
out.decl::<BusyBot>();
out.decl::<DayActivity>();
}

pub(super) fn methods(out: &mut Out) {
out.method(method::CARDS_LIST, "undefined", &out.name::<Cards>());
out.method(
method::ACTIVITY_TODAY,
&out.name::<ActivityTodayParams>(),
&out.name::<DayActivity>(),
);
}
60 changes: 60 additions & 0 deletions crates/botloft-core/src/protocol/island.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! What the island asks the daemon (spec 31): the cards that wait for the
//! owner and what the crews did today.
use serde::{Deserialize, Serialize};

use super::{ApprovalCard, EditLines, QuestionCard};
use crate::ids::BotId;

/// Every request and question waiting for the owner, oldest first, as the
/// phone sees them (spec 31.5, 28.5).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct Cards {
pub approvals: Vec<ApprovalCard>,
pub questions: Vec<QuestionCard>,
}

/// Params of `activity.today`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct ActivityTodayParams {
/// Where the day starts, in Unix ms: the app sends its local midnight.
pub since: i64,
}

/// What every crew did since `since`, counted from the database without a
/// bot's turn (spec 31.4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct DayActivity {
/// Tasks between bots that ended done.
pub tasks_done: u32,
/// Tasks that ended failed or expired.
pub tasks_failed: u32,
/// Messages one bot sent another.
pub bot_messages: u32,
/// Requests the owner allowed or denied.
pub requests_answered: u32,
/// Distinct files that edits counted in `lines` changed.
pub files_edited: u32,
/// Lines added and removed by the edits that finished well (spec 8.2).
pub lines: EditLines,
/// The bot that spent the most time in turns; `None` on a quiet day.
pub busiest: Option<BusyBot>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct BusyBot {
pub bot_id: BotId,
pub name: String,
pub color: String,
pub crew: String,
/// Time in turns since `since`, in ms.
pub work_ms: i64,
}
Loading
Loading