Skip to content

Commit 717c9d5

Browse files
authored
Merge pull request #3 from httpsphl/feat/m3-courier
feat: deliver owner messages to bots through the inbox (M3, part 1)
2 parents ac00970 + 300a7f2 commit 717c9d5

39 files changed

Lines changed: 2705 additions & 51 deletions

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

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ token: string, client: ClientInfo, protocol: number, };
101101

102102
export type HelloResult = { daemonVersion: string, protocol: number, };
103103

104+
/**
105+
* Deliveries still to go and those that gave up, for `system.status`.
106+
*/
107+
export type DeliveryBacklog = {
108+
/**
109+
* `pending` and `sending`.
110+
*/
111+
pending: number, dead: number, };
112+
104113
export type SystemStatus = { daemonVersion: string, protocol: number, uptimeMs: number,
105114
/**
106115
* Version reported by `claude --version`; `null` until the runtime probe runs.
@@ -109,7 +118,7 @@ claudeVersion: string | null,
109118
/**
110119
* Why bots cannot start (Claude Code missing or too old); `null` when fine.
111120
*/
112-
runtimeError: string | null, };
121+
runtimeError: string | null, deliveries: DeliveryBacklog, };
113122

114123
export type CrewsCreateParams = { name: string, };
115124

@@ -184,6 +193,115 @@ export type TerminalData = { botId: BotId, generation: number, offset: number,
184193
*/
185194
data: string, };
186195

196+
/**
197+
* Who wrote a message.
198+
*/
199+
export type SenderKind = "owner" | "bot" | "system";
200+
201+
/**
202+
* What a message is for.
203+
*/
204+
export type MessageKind = "note" | "task" | "result" | "system";
205+
206+
/**
207+
* Where a delivery is in the courier (spec 9.1).
208+
*/
209+
export type DeliveryState = "pending" | "sending" | "sent" | "dead";
210+
211+
export type TaskStatus = "open" | "done" | "failed" | "cancelled" | "expired";
212+
213+
/**
214+
* Text sent to a bot, by the owner, another bot or the daemon.
215+
*/
216+
export type Message = { id: MessageId, crewId: CrewId, fromKind: SenderKind,
217+
/**
218+
* Set when `fromKind` is `bot`.
219+
*/
220+
fromBotId: BotId | null, toBotId: BotId, kind: MessageKind, body: string,
221+
/**
222+
* The task this message asks for, answers or reports on.
223+
*/
224+
taskId: TaskId | null,
225+
/**
226+
* Unix time in milliseconds.
227+
*/
228+
createdAt: number, };
229+
230+
/**
231+
* Getting one message into one bot's inbox.
232+
*/
233+
export type Delivery = { id: DeliveryId, messageId: MessageId, botId: BotId, state: DeliveryState,
234+
/**
235+
* Failed attempts so far. Waiting for the bot to be ready is not one.
236+
*/
237+
attempts: number,
238+
/**
239+
* Unix time in milliseconds of the next try while `pending`.
240+
*/
241+
nextAttemptAt: number,
242+
/**
243+
* Why the last attempt failed; never contains the message body.
244+
*/
245+
lastError: string | null,
246+
/**
247+
* Unix time in milliseconds.
248+
*/
249+
updatedAt: number, };
250+
251+
/**
252+
* Work one bot asked another to do (spec 9.4).
253+
*/
254+
export type Task = { id: TaskId, crewId: CrewId, requesterBotId: BotId, assigneeBotId: BotId, status: TaskStatus,
255+
/**
256+
* Unix time in milliseconds.
257+
*/
258+
deadlineAt: number,
259+
/**
260+
* Position in a chain of delegations: 1 for a task nobody else caused.
261+
*/
262+
hops: number,
263+
/**
264+
* First task of the chain this one belongs to; `null` for that first task.
265+
*/
266+
originTaskId: TaskId | null, result: string | null,
267+
/**
268+
* Unix time in milliseconds.
269+
*/
270+
createdAt: number,
271+
/**
272+
* Unix time in milliseconds.
273+
*/
274+
updatedAt: number, };
275+
276+
/**
277+
* The owner writes to a bot.
278+
*/
279+
export type MessagesSendParams = { botId: BotId, body: string, };
280+
281+
/**
282+
* Newest first. Page back with `before` set to the oldest id received.
283+
*/
284+
export type MessagesListParams = { crewId?: CrewId,
285+
/**
286+
* Messages sent to or by this bot.
287+
*/
288+
botId?: BotId,
289+
/**
290+
* Only messages older than this one.
291+
*/
292+
before?: MessageId,
293+
/**
294+
* 1 to 200; 50 when absent.
295+
*/
296+
limit?: number, };
297+
298+
/**
299+
* Most recently updated first.
300+
*/
301+
export type DeliveriesListParams = { state?: DeliveryState, botId?: BotId, };
302+
303+
export type DeliveryIdParams = { deliveryId: DeliveryId, };
304+
187305
/** Params and result of every request method. */
188306
export interface RpcMethods {
189307
"session.hello": { params: HelloParams; result: HelloResult };
@@ -203,6 +321,10 @@ export interface RpcMethods {
203321
"terminal.detach": { params: BotIdParams; result: null };
204322
"terminal.write": { params: TerminalWriteParams; result: null };
205323
"terminal.resize": { params: TerminalResizeParams; result: null };
324+
"messages.send": { params: MessagesSendParams; result: Message };
325+
"messages.list": { params: MessagesListParams; result: Array<Message> };
326+
"deliveries.list": { params: DeliveriesListParams; result: Array<Delivery> };
327+
"deliveries.retry": { params: DeliveryIdParams; result: Delivery };
206328
}
207329

208330
/** Params of every server notification. */
@@ -211,6 +333,9 @@ export interface RpcNotifications {
211333
"bot.changed": Bot;
212334
"bot.state": BotStateChanged;
213335
"terminal.data": TerminalData;
336+
"message.created": Message;
337+
"delivery.changed": Delivery;
338+
"task.changed": Task;
214339
}
215340

216341
export const RpcErrorCode = {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! The text a bot reads when a message arrives (spec 9.3). The first line
2+
//! always says who sent it; bots are told so in their rules.
3+
4+
use crate::protocol::{MessageKind, Task};
5+
6+
/// Who the first line names.
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+
pub enum Sender<'a> {
9+
Owner,
10+
Bot {
11+
handle: &'a str,
12+
},
13+
/// The daemon, e.g. to say a task expired.
14+
Botloft,
15+
}
16+
17+
#[derive(Debug, Clone, Copy)]
18+
pub struct Envelope<'a> {
19+
pub from: Sender<'a>,
20+
/// Display name of the crew.
21+
pub crew: &'a str,
22+
pub kind: MessageKind,
23+
/// The task a `task`, `result` or `system` message is about.
24+
pub task: Option<&'a Task>,
25+
pub body: &'a str,
26+
}
27+
28+
impl Envelope<'_> {
29+
/// Renders the envelope as of `now_ms`, which sets the relative deadline.
30+
pub fn render(&self, now_ms: i64) -> String {
31+
let mut header = format!("[botloft] from {} · crew {}", self.sender(), self.crew);
32+
if let Some(task) = self.task {
33+
let detail = match self.kind {
34+
MessageKind::Task => {
35+
format!("task {} · {}", task.id, due(task.deadline_at, now_ms))
36+
}
37+
MessageKind::Result => format!("result of task {} · {}", task.id, task.status),
38+
MessageKind::Note | MessageKind::System => {
39+
format!("task {} · {}", task.id, task.status)
40+
}
41+
};
42+
header.push_str(" · ");
43+
header.push_str(&detail);
44+
}
45+
let mut text = header;
46+
if let Sender::Bot { handle } = self.from {
47+
text.push_str(&format!("\nReply with send_message(to: \"{handle}\")."));
48+
if let (MessageKind::Task, Some(task)) = (self.kind, self.task) {
49+
text.push_str(&format!(
50+
" When the task is done, call complete_task(task_id: \"{}\").",
51+
task.id
52+
));
53+
}
54+
}
55+
text.push_str("\n\n");
56+
text.push_str(self.body);
57+
text
58+
}
59+
60+
fn sender(&self) -> String {
61+
match self.from {
62+
// No `@`: a bot may well be called "Owner".
63+
Sender::Owner => "the owner".to_owned(),
64+
Sender::Bot { handle } => format!("@{handle}"),
65+
Sender::Botloft => "Botloft".to_owned(),
66+
}
67+
}
68+
}
69+
70+
/// Time left until `deadline_ms`, rounded for a reader who does not know
71+
/// the current time.
72+
fn due(deadline_ms: i64, now_ms: i64) -> String {
73+
let left = deadline_ms.saturating_sub(now_ms);
74+
if left <= 0 {
75+
return "overdue".to_owned();
76+
}
77+
let minutes = (left + 59_999) / 60_000;
78+
if minutes < 90 {
79+
format!("due in {minutes} min")
80+
} else {
81+
format!("due in {} h", (minutes + 30) / 60)
82+
}
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
use crate::ids::{BotId, CrewId, TaskId};
89+
use crate::protocol::TaskStatus;
90+
91+
const MINUTE: i64 = 60_000;
92+
93+
fn task(deadline_at: i64, status: TaskStatus) -> Task {
94+
Task {
95+
id: "tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0"
96+
.parse::<TaskId>()
97+
.expect("id"),
98+
crew_id: CrewId::generate(),
99+
requester_bot_id: BotId::generate(),
100+
assignee_bot_id: BotId::generate(),
101+
status,
102+
deadline_at,
103+
hops: 1,
104+
origin_task_id: None,
105+
result: None,
106+
created_at: 0,
107+
updated_at: 0,
108+
}
109+
}
110+
111+
fn envelope<'a>(from: Sender<'a>, kind: MessageKind, task: Option<&'a Task>) -> Envelope<'a> {
112+
Envelope {
113+
from,
114+
crew: "Site",
115+
kind,
116+
task,
117+
body: "Check the build.\nThanks!",
118+
}
119+
}
120+
121+
#[test]
122+
fn a_task_names_its_id_deadline_and_how_to_finish_it() {
123+
let task = task(120 * MINUTE, TaskStatus::Open);
124+
let text = envelope(
125+
Sender::Bot { handle: "revisor" },
126+
MessageKind::Task,
127+
Some(&task),
128+
)
129+
.render(0);
130+
assert_eq!(
131+
text,
132+
"[botloft] from @revisor · crew Site · task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · due in 2 h\n\
133+
Reply with send_message(to: \"revisor\"). When the task is done, \
134+
call complete_task(task_id: \"tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0\").\n\
135+
\n\
136+
Check the build.\nThanks!"
137+
);
138+
}
139+
140+
#[test]
141+
fn owner_notes_have_no_reply_line() {
142+
let text = envelope(Sender::Owner, MessageKind::Note, None).render(0);
143+
assert_eq!(
144+
text,
145+
"[botloft] from the owner · crew Site\n\nCheck the build.\nThanks!"
146+
);
147+
}
148+
149+
#[test]
150+
fn results_and_notices_carry_the_task_status() {
151+
let done = task(0, TaskStatus::Done);
152+
let result = envelope(
153+
Sender::Bot { handle: "writer" },
154+
MessageKind::Result,
155+
Some(&done),
156+
)
157+
.render(0);
158+
assert!(result.starts_with(
159+
"[botloft] from @writer · crew Site · result of task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · done\n\
160+
Reply with send_message(to: \"writer\").\n\n"
161+
));
162+
let expired = task(0, TaskStatus::Expired);
163+
let notice = envelope(Sender::Botloft, MessageKind::System, Some(&expired)).render(0);
164+
assert!(notice.starts_with(
165+
"[botloft] from Botloft · crew Site · task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · expired\n\n"
166+
));
167+
}
168+
169+
#[test]
170+
fn deadlines_read_as_time_left() {
171+
assert_eq!(due(0, 0), "overdue");
172+
assert_eq!(due(1, 0), "due in 1 min");
173+
assert_eq!(due(45 * MINUTE, 0), "due in 45 min");
174+
assert_eq!(due(89 * MINUTE, 0), "due in 89 min");
175+
assert_eq!(due(90 * MINUTE, 0), "due in 2 h");
176+
assert_eq!(due(26 * 60 * MINUTE, 0), "due in 26 h");
177+
assert_eq!(due(0, 5 * MINUTE), "overdue");
178+
}
179+
}

‎crates/botloft-core/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! the daemon and the app.
33
44
pub mod avatar;
5+
pub mod envelope;
56
pub mod ids;
67
pub mod protocol;
78
pub mod slug;

0 commit comments

Comments
 (0)