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
133 changes: 132 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ token: string, client: ClientInfo, protocol: number, };

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

/**
* Deliveries still to go and those that gave up, for `system.status`.
*/
export type DeliveryBacklog = {
/**
* `pending` and `sending`.
*/
pending: number, dead: number, };

export type SystemStatus = { daemonVersion: string, protocol: number, uptimeMs: number,
/**
* Version reported by `claude --version`; `null` until the runtime probe runs.
Expand All @@ -109,7 +118,7 @@ claudeVersion: string | null,
/**
* Why bots cannot start (Claude Code missing or too old); `null` when fine.
*/
runtimeError: string | null, };
runtimeError: string | null, deliveries: DeliveryBacklog, };

export type CrewsCreateParams = { name: string, };

Expand Down Expand Up @@ -184,6 +193,120 @@ export type TerminalData = { botId: BotId, generation: number, offset: number,
*/
data: string, };

/**
* Who wrote a message.
*/
export type SenderKind = "owner" | "bot" | "system";

/**
* What a message is for.
*/
export type MessageKind = "note" | "task" | "result" | "system";

/**
* Where a delivery is in the courier (spec 9.1).
*/
export type DeliveryState = "pending" | "sending" | "sent" | "dead";

export type TaskStatus = "open" | "done" | "failed" | "cancelled" | "expired";

/**
* Text sent to a bot, by the owner, another bot or the daemon.
*/
export type Message = { id: MessageId, crewId: CrewId, fromKind: SenderKind,
/**
* Set when `fromKind` is `bot`.
*/
fromBotId: BotId | null, toBotId: BotId, kind: MessageKind, body: string,
/**
* The task this message asks for, answers or reports on.
*/
taskId: TaskId | null,
/**
* Unix time in milliseconds.
*/
createdAt: number, };

/**
* Getting one message into one bot's inbox.
*/
export type Delivery = { id: DeliveryId, messageId: MessageId, botId: BotId, state: DeliveryState,
/**
* Failed attempts so far. Waiting for the bot to be ready is not one.
*/
attempts: number,
/**
* Unix time in milliseconds of the next try while `pending`.
*/
nextAttemptAt: number,
/**
* Why the last attempt failed; never contains the message body.
*/
lastError: string | null,
/**
* Unix time in milliseconds.
*/
updatedAt: number, };

/**
* Work one bot asked another to do (spec 9.4).
*/
export type Task = { id: TaskId, crewId: CrewId, requesterBotId: BotId, assigneeBotId: BotId, status: TaskStatus,
/**
* Unix time in milliseconds.
*/
deadlineAt: number,
/**
* Position in a chain of delegations: 1 for a task nobody else caused.
*/
hops: number,
/**
* First task of the chain this one belongs to; `null` for that first task.
*/
originTaskId: TaskId | null, result: string | null,
/**
* Unix time in milliseconds.
*/
createdAt: number,
/**
* Unix time in milliseconds.
*/
updatedAt: number, };

/**
* The owner writes to a bot.
*/
export type MessagesSendParams = { botId: BotId, body: string, };

/**
* Newest first. Page back with `before` set to the oldest id received.
*/
export type MessagesListParams = { crewId?: CrewId,
/**
* Messages sent to or by this bot.
*/
botId?: BotId,
/**
* Only messages older than this one.
*/
before?: MessageId,
/**
* 1 to 200; 50 when absent.
*/
limit?: number, };

/**
* Most recently updated first.
*/
export type DeliveriesListParams = { state?: DeliveryState, botId?: BotId, };

export type DeliveryIdParams = { deliveryId: DeliveryId, };

/**
* Newest first.
*/
export type TasksListParams = { crewId?: CrewId, status?: TaskStatus, };

/** Params and result of every request method. */
export interface RpcMethods {
"session.hello": { params: HelloParams; result: HelloResult };
Expand All @@ -203,6 +326,11 @@ export interface RpcMethods {
"terminal.detach": { params: BotIdParams; result: null };
"terminal.write": { params: TerminalWriteParams; result: null };
"terminal.resize": { params: TerminalResizeParams; result: null };
"messages.send": { params: MessagesSendParams; result: Message };
"messages.list": { params: MessagesListParams; result: Array<Message> };
"deliveries.list": { params: DeliveriesListParams; result: Array<Delivery> };
"deliveries.retry": { params: DeliveryIdParams; result: Delivery };
"tasks.list": { params: TasksListParams; result: Array<Task> };
}

/** Params of every server notification. */
Expand All @@ -211,6 +339,9 @@ export interface RpcNotifications {
"bot.changed": Bot;
"bot.state": BotStateChanged;
"terminal.data": TerminalData;
"message.created": Message;
"delivery.changed": Delivery;
"task.changed": Task;
}

export const RpcErrorCode = {
Expand Down
179 changes: 179 additions & 0 deletions crates/botloft-core/src/envelope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! The text a bot reads when a message arrives (spec 9.3). The first line
//! always says who sent it; bots are told so in their rules.

use crate::protocol::{MessageKind, Task};

/// Who the first line names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sender<'a> {
Owner,
Bot {
handle: &'a str,
},
/// The daemon, e.g. to say a task expired.
Botloft,
}

#[derive(Debug, Clone, Copy)]
pub struct Envelope<'a> {
pub from: Sender<'a>,
/// Display name of the crew.
pub crew: &'a str,
pub kind: MessageKind,
/// The task a `task`, `result` or `system` message is about.
pub task: Option<&'a Task>,
pub body: &'a str,
}

impl Envelope<'_> {
/// Renders the envelope as of `now_ms`, which sets the relative deadline.
pub fn render(&self, now_ms: i64) -> String {
let mut header = format!("[botloft] from {} · crew {}", self.sender(), self.crew);
if let Some(task) = self.task {
let detail = match self.kind {
MessageKind::Task => {
format!("task {} · {}", task.id, due(task.deadline_at, now_ms))
}
MessageKind::Result => format!("result of task {} · {}", task.id, task.status),
MessageKind::Note | MessageKind::System => {
format!("task {} · {}", task.id, task.status)
}
};
header.push_str(" · ");
header.push_str(&detail);
}
let mut text = header;
if let Sender::Bot { handle } = self.from {
text.push_str(&format!("\nReply with send_message(to: \"{handle}\")."));
if let (MessageKind::Task, Some(task)) = (self.kind, self.task) {
text.push_str(&format!(
" When the task is done, call complete_task(task_id: \"{}\").",
task.id
));
}
}
text.push_str("\n\n");
text.push_str(self.body);
text
}

fn sender(&self) -> String {
match self.from {
// No `@`: a bot may well be called "Owner".
Sender::Owner => "the owner".to_owned(),
Sender::Bot { handle } => format!("@{handle}"),
Sender::Botloft => "Botloft".to_owned(),
}
}
}

/// Time left until `deadline_ms`, rounded for a reader who does not know
/// the current time.
pub fn due(deadline_ms: i64, now_ms: i64) -> String {
let left = deadline_ms.saturating_sub(now_ms);
if left <= 0 {
return "overdue".to_owned();
}
let minutes = (left + 59_999) / 60_000;
if minutes < 90 {
format!("due in {minutes} min")
} else {
format!("due in {} h", (minutes + 30) / 60)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{BotId, CrewId, TaskId};
use crate::protocol::TaskStatus;

const MINUTE: i64 = 60_000;

fn task(deadline_at: i64, status: TaskStatus) -> Task {
Task {
id: "tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0"
.parse::<TaskId>()
.expect("id"),
crew_id: CrewId::generate(),
requester_bot_id: BotId::generate(),
assignee_bot_id: BotId::generate(),
status,
deadline_at,
hops: 1,
origin_task_id: None,
result: None,
created_at: 0,
updated_at: 0,
}
}

fn envelope<'a>(from: Sender<'a>, kind: MessageKind, task: Option<&'a Task>) -> Envelope<'a> {
Envelope {
from,
crew: "Site",
kind,
task,
body: "Check the build.\nThanks!",
}
}

#[test]
fn a_task_names_its_id_deadline_and_how_to_finish_it() {
let task = task(120 * MINUTE, TaskStatus::Open);
let text = envelope(
Sender::Bot { handle: "revisor" },
MessageKind::Task,
Some(&task),
)
.render(0);
assert_eq!(
text,
"[botloft] from @revisor · crew Site · task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · due in 2 h\n\
Reply with send_message(to: \"revisor\"). When the task is done, \
call complete_task(task_id: \"tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0\").\n\
\n\
Check the build.\nThanks!"
);
}

#[test]
fn owner_notes_have_no_reply_line() {
let text = envelope(Sender::Owner, MessageKind::Note, None).render(0);
assert_eq!(
text,
"[botloft] from the owner · crew Site\n\nCheck the build.\nThanks!"
);
}

#[test]
fn results_and_notices_carry_the_task_status() {
let done = task(0, TaskStatus::Done);
let result = envelope(
Sender::Bot { handle: "writer" },
MessageKind::Result,
Some(&done),
)
.render(0);
assert!(result.starts_with(
"[botloft] from @writer · crew Site · result of task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · done\n\
Reply with send_message(to: \"writer\").\n\n"
));
let expired = task(0, TaskStatus::Expired);
let notice = envelope(Sender::Botloft, MessageKind::System, Some(&expired)).render(0);
assert!(notice.starts_with(
"[botloft] from Botloft · crew Site · task tsk_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0 · expired\n\n"
));
}

#[test]
fn deadlines_read_as_time_left() {
assert_eq!(due(0, 0), "overdue");
assert_eq!(due(1, 0), "due in 1 min");
assert_eq!(due(45 * MINUTE, 0), "due in 45 min");
assert_eq!(due(89 * MINUTE, 0), "due in 89 min");
assert_eq!(due(90 * MINUTE, 0), "due in 2 h");
assert_eq!(due(26 * 60 * MINUTE, 0), "due in 26 h");
assert_eq!(due(0, 5 * MINUTE), "overdue");
}
}
1 change: 1 addition & 0 deletions crates/botloft-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! the daemon and the app.

pub mod avatar;
pub mod envelope;
pub mod ids;
pub mod protocol;
pub mod slug;
Expand Down
Loading
Loading