Skip to content

Commit 045fed6

Browse files
authored
Merge pull request #266 from httpsphl/feat/island
feat(island): the cards that wait and today's activity, for the island
2 parents 5540c10 + 1d47750 commit 045fed6

16 files changed

Lines changed: 773 additions & 3 deletions

File tree

‎app/src/lib/fake.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FakeCrewAccess } from "./fakeCrewAccess";
1616
import { crewHandlers } from "./fakeCrews";
1717
import { FakeDesktop } from "./fakeDesktop";
1818
import { FakeFiles } from "./fakeFiles";
19+
import { islandHandlers } from "./fakeIsland";
1920
import { FakeLesson } from "./fakeLesson";
2021
import { FakeMcp } from "./fakeMcp";
2122
import { mobileHandlers } from "./fakeMobile";
@@ -325,6 +326,7 @@ export class FakeBotloft implements BotloftApi {
325326
...this.reactions.handlers(),
326327
...this.lesson.handlers(),
327328
...usageHandlers(this),
329+
...islandHandlers(this),
328330
...backupHandlers(this),
329331
...cloudHandlers(this),
330332
...autoBackupHandlers(this),

‎app/src/lib/fakeIsland.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from "vitest";
2+
import { FakeBotloft } from "./fake";
3+
4+
describe("the fake's island methods", () => {
5+
it("lists what waits with the phone's rules, oldest first", async () => {
6+
const fake = new FakeBotloft();
7+
const ops = fake.addCrew("Ops");
8+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
9+
fake.chat.ask(scout.id, "Bash", "git status", JSON.stringify({ command: "git status" }));
10+
fake.chat.ask(scout.id, "mcp__github__create_issue", "Fix it", "{}");
11+
fake.chat.ask(scout.id, "Bash", "echo", `{"command": "echo ${"x".repeat(20)}…`);
12+
fake.questions.ask(scout.id, "Coffee?", ["Yes", "No"]);
13+
14+
const cards = await fake.call("cards.list");
15+
expect(
16+
cards.approvals.map((card) => [card.text.slice(0, 10), card.cut, card.atComputer]),
17+
).toEqual([
18+
["git status", false, false],
19+
["{}", false, true],
20+
['{"command"', true, false],
21+
]);
22+
expect(cards.approvals[0]?.bot.name).toBe("Scout");
23+
expect(cards.approvals[0]?.crew).toBe("Ops");
24+
expect(cards.questions.map((card) => card.text)).toEqual(["Coffee?"]);
25+
});
26+
27+
it("counts the edits and the time in turns since the start of the day", async () => {
28+
const fake = new FakeBotloft();
29+
const ops = fake.addCrew("Ops");
30+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
31+
fake.chat.tool(scout.id, "Edit", {
32+
status: "done",
33+
file: "C:\\work\\a.md",
34+
lines: { added: 3, removed: 1 },
35+
});
36+
fake.chat.turn(scout.id);
37+
38+
const today = await fake.call("activity.today", { since: 0 });
39+
expect(today.filesEdited).toBe(1);
40+
expect(today.lines).toEqual({ added: 3, removed: 1 });
41+
expect(today.busiest?.name).toBe("Scout");
42+
const later = await fake.call("activity.today", { since: fake.now + 1 });
43+
expect(later.filesEdited).toBe(0);
44+
expect(later.busiest).toBeNull();
45+
});
46+
});

‎app/src/lib/fakeIsland.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// The fake daemon's island methods (spec 31): the cards that wait, built
2+
// from the fake chat and questions with the phone's rules (28.5), and what
3+
// the crews did since a moment, counted like the daemon's `today.rs`.
4+
5+
import type { FakeBotloft, Handlers } from "./fake";
6+
import type { ApprovalCard, BusyBot, QuestionCard } from "./protocol.gen";
7+
8+
/** The longest text a card shows whole (the daemon's `CARD_TEXT_MAX`). */
9+
const CARD_TEXT_MAX = 8 * 1024;
10+
11+
/** The tools a card may allow; the rest is answered in the Botloft. */
12+
const MAY_ALLOW = new Set([
13+
"Bash",
14+
"PowerShell",
15+
"WebFetch",
16+
"WebSearch",
17+
"Read",
18+
"Write",
19+
"Edit",
20+
"MultiEdit",
21+
"NotebookEdit",
22+
"ExitPlanMode",
23+
"mcp__botloft__show_plan",
24+
]);
25+
26+
/** What to read before allowing, and whether it is not all of the request. */
27+
function textOf(input: string): { text: string; cut: boolean } {
28+
let parsed: unknown;
29+
try {
30+
parsed = JSON.parse(input);
31+
} catch {
32+
return { text: input.slice(0, CARD_TEXT_MAX), cut: true };
33+
}
34+
const fields = parsed as { command?: unknown; plan?: unknown };
35+
const text =
36+
typeof fields.command === "string"
37+
? fields.command
38+
: typeof fields.plan === "string"
39+
? fields.plan
40+
: input;
41+
return text.length > CARD_TEXT_MAX
42+
? { text: text.slice(0, CARD_TEXT_MAX), cut: true }
43+
: { text, cut: false };
44+
}
45+
46+
export function islandHandlers(fake: FakeBotloft): Pick<Handlers, "cards.list" | "activity.today"> {
47+
const who = (botId: string) => {
48+
const bot = fake.bot(botId, false);
49+
return { bot: { name: bot.name, color: bot.color }, crew: fake.crew(bot.crewId, false).name };
50+
};
51+
return {
52+
"cards.list": () => {
53+
const approvals: ApprovalCard[] = [];
54+
for (const item of fake.chat.items) {
55+
if (item.body.kind !== "approval" || item.body.status !== "pending") {
56+
continue;
57+
}
58+
approvals.push({
59+
approvalId: item.body.approvalId,
60+
...who(item.botId),
61+
createdAt: item.createdAt,
62+
toolName: item.body.toolName,
63+
summary: item.body.summary,
64+
...(item.body.explanation !== null && { explanation: item.body.explanation }),
65+
...textOf(item.body.input),
66+
atComputer: !MAY_ALLOW.has(item.body.toolName),
67+
});
68+
}
69+
const questions: QuestionCard[] = fake.questions.asked
70+
.map((entry) => entry.question)
71+
.filter((question) => question.status === "open")
72+
.map((question) => ({
73+
questionId: question.id,
74+
...who(question.botId),
75+
createdAt: question.createdAt,
76+
text: question.text,
77+
options: question.options,
78+
}));
79+
const oldest = (a: { createdAt: number }, b: { createdAt: number }) =>
80+
a.createdAt - b.createdAt;
81+
return { approvals: approvals.sort(oldest), questions: questions.sort(oldest) };
82+
},
83+
"activity.today": ({ since }) => {
84+
const tasks = [...fake.conversation.tasks.values()].filter((task) => task.updatedAt >= since);
85+
const files = new Set<string>();
86+
const lines = { added: 0, removed: 0 };
87+
let requestsAnswered = 0;
88+
const work = new Map<string, number>();
89+
for (const item of fake.chat.items) {
90+
const body = item.body;
91+
if (
92+
body.kind === "tool" &&
93+
item.createdAt >= since &&
94+
body.status === "done" &&
95+
body.lines
96+
) {
97+
if (body.file) files.add(body.file);
98+
lines.added += body.lines.added;
99+
lines.removed += body.lines.removed;
100+
}
101+
if (body.kind === "approval" && item.updatedAt >= since) {
102+
if (body.status === "allowed" || body.status === "denied") requestsAnswered += 1;
103+
}
104+
if (body.kind === "turn" && item.createdAt >= since) {
105+
work.set(item.botId, (work.get(item.botId) ?? 0) + body.durationMs);
106+
}
107+
}
108+
const [top] = [...work.entries()].filter(([, ms]) => ms > 0).sort((a, b) => b[1] - a[1]);
109+
let busiest: BusyBot | null = null;
110+
if (top) {
111+
const bot = fake.bot(top[0], false);
112+
busiest = {
113+
botId: bot.id,
114+
name: bot.name,
115+
color: bot.color,
116+
crew: fake.crew(bot.crewId, false).name,
117+
workMs: top[1],
118+
};
119+
}
120+
return {
121+
tasksDone: tasks.filter((task) => task.status === "done").length,
122+
tasksFailed: tasks.filter((task) => task.status === "failed" || task.status === "expired")
123+
.length,
124+
botMessages: fake.conversation.messages.filter(
125+
(message) => message.fromKind === "bot" && message.createdAt >= since,
126+
).length,
127+
requestsAnswered,
128+
filesEdited: files.size,
129+
lines,
130+
busiest,
131+
};
132+
},
133+
};
134+
}

‎app/src/lib/protocol.gen.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,61 @@ export type ToPhone = { "t": "snapshot", approvals: Array<ApprovalCard>, questio
16301630
*/
16311631
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, };
16321632

1633+
/**
1634+
* Every request and question waiting for the owner, oldest first, as the
1635+
* phone sees them (spec 31.5, 28.5).
1636+
*/
1637+
export type Cards = { approvals: Array<ApprovalCard>, questions: Array<QuestionCard>, };
1638+
1639+
/**
1640+
* Params of `activity.today`.
1641+
*/
1642+
export type ActivityTodayParams = {
1643+
/**
1644+
* Where the day starts, in Unix ms: the app sends its local midnight.
1645+
*/
1646+
since: number, };
1647+
1648+
export type BusyBot = { botId: BotId, name: string, color: string, crew: string,
1649+
/**
1650+
* Time in turns since `since`, in ms.
1651+
*/
1652+
workMs: number, };
1653+
1654+
/**
1655+
* What every crew did since `since`, counted from the database without a
1656+
* bot's turn (spec 31.4).
1657+
*/
1658+
export type DayActivity = {
1659+
/**
1660+
* Tasks between bots that ended done.
1661+
*/
1662+
tasksDone: number,
1663+
/**
1664+
* Tasks that ended failed or expired.
1665+
*/
1666+
tasksFailed: number,
1667+
/**
1668+
* Messages one bot sent another.
1669+
*/
1670+
botMessages: number,
1671+
/**
1672+
* Requests the owner allowed or denied.
1673+
*/
1674+
requestsAnswered: number,
1675+
/**
1676+
* Distinct files that edits counted in `lines` changed.
1677+
*/
1678+
filesEdited: number,
1679+
/**
1680+
* Lines added and removed by the edits that finished well (spec 8.2).
1681+
*/
1682+
lines: EditLines,
1683+
/**
1684+
* The bot that spent the most time in turns; `None` on a quiet day.
1685+
*/
1686+
busiest: BusyBot | null, };
1687+
16331688
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
16341689
/**
16351690
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -2374,6 +2429,8 @@ export interface RpcMethods {
23742429
"mobile.pair_cancel": { params: MobilePairIdParams; result: null };
23752430
"mobile.pair_confirm": { params: MobilePairConfirmParams; result: null };
23762431
"mobile.revoke": { params: MobileRevokeParams; result: MobileStatus };
2432+
"cards.list": { params: undefined; result: Cards };
2433+
"activity.today": { params: ActivityTodayParams; result: DayActivity };
23772434
"routines.list": { params: RoutinesListParams; result: Array<Routine> };
23782435
"routines.create": { params: RoutinesCreateParams; result: Routine };
23792436
"routines.update": { params: RoutinesUpdateParams; result: Routine };

‎crates/botloft-core/src/protocol/export.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ mod cloud;
2626
mod crew_access;
2727
#[path = "export_desktop.rs"]
2828
mod desktop;
29+
#[path = "export_island.rs"]
30+
mod island;
2931
#[path = "export_mcp.rs"]
3032
mod mcp;
3133
#[path = "export_mobile.rs"]
@@ -210,6 +212,7 @@ fn export_bindings() {
210212
backup::decls(&mut out);
211213
cloud::decls(&mut out);
212214
mobile::decls(&mut out);
215+
island::decls(&mut out);
213216
out.decl::<ApprovalItem>();
214217
usage::decls(&mut out);
215218
questions::decls(&mut out);
@@ -411,6 +414,7 @@ fn export_bindings() {
411414
backup::methods(&mut out);
412415
cloud::methods(&mut out);
413416
mobile::methods(&mut out);
417+
island::methods(&mut out);
414418
routines::methods(&mut out);
415419
questions::methods(&mut out);
416420
reactions::methods(&mut out);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//! The island part of the TypeScript bindings (spec 31).
2+
3+
use super::super::*;
4+
use super::Out;
5+
6+
/// After the mobile cards, which `Cards` holds.
7+
pub(super) fn decls(out: &mut Out) {
8+
out.decl::<Cards>();
9+
out.decl::<ActivityTodayParams>();
10+
out.decl::<BusyBot>();
11+
out.decl::<DayActivity>();
12+
}
13+
14+
pub(super) fn methods(out: &mut Out) {
15+
out.method(method::CARDS_LIST, "undefined", &out.name::<Cards>());
16+
out.method(
17+
method::ACTIVITY_TODAY,
18+
&out.name::<ActivityTodayParams>(),
19+
&out.name::<DayActivity>(),
20+
);
21+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! What the island asks the daemon (spec 31): the cards that wait for the
2+
//! owner and what the crews did today.
3+
4+
use serde::{Deserialize, Serialize};
5+
6+
use super::{ApprovalCard, EditLines, QuestionCard};
7+
use crate::ids::BotId;
8+
9+
/// Every request and question waiting for the owner, oldest first, as the
10+
/// phone sees them (spec 31.5, 28.5).
11+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12+
#[serde(rename_all = "camelCase")]
13+
#[cfg_attr(test, derive(ts_rs::TS))]
14+
pub struct Cards {
15+
pub approvals: Vec<ApprovalCard>,
16+
pub questions: Vec<QuestionCard>,
17+
}
18+
19+
/// Params of `activity.today`.
20+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
22+
#[cfg_attr(test, derive(ts_rs::TS))]
23+
pub struct ActivityTodayParams {
24+
/// Where the day starts, in Unix ms: the app sends its local midnight.
25+
pub since: i64,
26+
}
27+
28+
/// What every crew did since `since`, counted from the database without a
29+
/// bot's turn (spec 31.4).
30+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31+
#[serde(rename_all = "camelCase")]
32+
#[cfg_attr(test, derive(ts_rs::TS))]
33+
pub struct DayActivity {
34+
/// Tasks between bots that ended done.
35+
pub tasks_done: u32,
36+
/// Tasks that ended failed or expired.
37+
pub tasks_failed: u32,
38+
/// Messages one bot sent another.
39+
pub bot_messages: u32,
40+
/// Requests the owner allowed or denied.
41+
pub requests_answered: u32,
42+
/// Distinct files that edits counted in `lines` changed.
43+
pub files_edited: u32,
44+
/// Lines added and removed by the edits that finished well (spec 8.2).
45+
pub lines: EditLines,
46+
/// The bot that spent the most time in turns; `None` on a quiet day.
47+
pub busiest: Option<BusyBot>,
48+
}
49+
50+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51+
#[serde(rename_all = "camelCase")]
52+
#[cfg_attr(test, derive(ts_rs::TS))]
53+
pub struct BusyBot {
54+
pub bot_id: BotId,
55+
pub name: String,
56+
pub color: String,
57+
pub crew: String,
58+
/// Time in turns since `since`, in ms.
59+
pub work_ms: i64,
60+
}

0 commit comments

Comments
 (0)