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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const inbound = (body: string, at?: number) =>
kind: "note",
body,
taskId: null,
routineId: null,
attachments: [],
createdAt: at ?? start,
},
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { botHandlers } from "./fakeBots";
import { FakeChat } from "./fakeChat";
import { FakeConversation } from "./fakeConversation";
import { crewHandlers } from "./fakeCrews";
import { FakeRoutines } from "./fakeRoutines";
import { conflict, invalid, notFound, slugify } from "./fakeRules";
import {
type Bot,
Expand Down Expand Up @@ -56,6 +57,7 @@ export class FakeBotloft implements BotloftApi {
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
readonly conversation = new FakeConversation(this);
readonly routines = new FakeRoutines(this);
/** Clock for created and updated times. */
now = Date.now();
closed = false;
Expand Down Expand Up @@ -206,5 +208,6 @@ export class FakeBotloft implements BotloftApi {
...botHandlers(this),
...this.chat.handlers(),
...this.conversation.handlers(),
...this.routines.handlers(),
};
}
2 changes: 2 additions & 0 deletions app/src/lib/fakeConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class FakeConversation {
kind: options.kind ?? (options.from ? "note" : "system"),
body: options.body,
taskId: options.taskId ?? null,
routineId: null,
attachments: [],
createdAt: this.fake.now,
};
Expand Down Expand Up @@ -127,6 +128,7 @@ export class FakeConversation {
kind: "note",
body: body.trim(),
taskId: null,
routineId: null,
attachments: files.map((file) => this.saved(file)),
createdAt: this.fake.now,
};
Expand Down
123 changes: 123 additions & 0 deletions app/src/lib/fakeRoutines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// The fake daemon's `routines.*` methods (spec 20.8): routines kept in
// memory, with "run now" sending the routine's message to the bot. The
// schedule itself is the daemon's business and is not played here.

import type { FakeBotloft, Handlers } from "./fake";
import { conflict, invalid, notFound } from "./fakeRules";
import type { Routine, RoutineId, RoutineRun } from "./protocol.gen";

type RoutineMethods = Extract<keyof Handlers, `routines.${string}`>;

export class FakeRoutines {
readonly routines = new Map<RoutineId, Routine>();
readonly runs: RoutineRun[] = [];

constructor(private readonly fake: FakeBotloft) {}

handlers(): Pick<Handlers, RoutineMethods> {
return {
"routines.list": ({ botId }) =>
[...this.routines.values()].filter(
(routine) =>
routine.archivedAt === null && (botId === undefined || routine.botId === botId),
),
"routines.create": ({ botId, name, prompt, schedule, timezone, overlap, missed }) => {
this.fake.bot(botId);
if (!name.trim()) {
throw invalid("name must not be empty");
}
const routine: Routine = {
id: this.fake.id("rtn"),
botId,
name: name.trim(),
prompt,
schedule,
timezone,
overlap: overlap ?? "skip",
missed: missed ?? "run_once",
enabled: true,
nextRunAt: this.fake.now + 60 * 60_000,
lastRun: null,
createdAt: this.fake.now,
updatedAt: this.fake.now,
archivedAt: null,
};
this.routines.set(routine.id, routine);
return this.changed(routine);
},
"routines.update": ({ routineId, ...changes }) => {
const routine = this.routine(routineId);
for (const [key, value] of Object.entries(changes)) {
if (value !== undefined) {
Object.assign(routine, { [key]: value });
}
}
routine.updatedAt = this.fake.now;
return this.changed(routine);
},
"routines.setEnabled": ({ routineId, enabled }) => {
const routine = this.routine(routineId);
routine.enabled = enabled;
routine.nextRunAt = enabled ? this.fake.now + 60 * 60_000 : null;
return this.changed(routine);
},
"routines.runNow": ({ routineId }) => {
const routine = this.routine(routineId);
const { message } = this.fake.conversation.say({
to: routine.botId,
body: routine.prompt,
kind: "routine",
});
message.routineId = routine.id;
const run: RoutineRun = {
id: this.fake.id("rrn"),
routineId,
scheduledFor: this.fake.now,
status: "queued",
reason: null,
skippedCount: 0,
messageId: message.id,
createdAt: this.fake.now,
finishedAt: null,
};
this.runs.push(run);
routine.lastRun = run;
this.fake.emit({ name: "routine.run", params: run });
this.changed(routine);
return run;
},
"routines.archive": ({ routineId }) => {
const routine = this.routine(routineId, false);
if (routine.archivedAt === null) {
routine.archivedAt = this.fake.now;
routine.nextRunAt = null;
this.changed(routine);
}
return routine;
},
"routines.runs": ({ routineId, limit }) => {
this.routine(routineId, false);
return this.runs
.filter((run) => run.routineId === routineId)
.reverse()
.slice(0, limit ?? 50);
},
};
}

private routine(id: RoutineId, active = true): Routine {
const routine = this.routines.get(id);
if (!routine) {
throw notFound(`routine ${id}`);
}
if (active && routine.archivedAt !== null) {
throw conflict(`routine ${id} is archived`);
}
return routine;
}

private changed(routine: Routine): Routine {
this.fake.emit({ name: "routine.changed", params: routine });
return routine;
}
}
100 changes: 99 additions & 1 deletion app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export type ApprovalId = string;
*/
export type AttachmentId = string;

/**
* Identifies a routine (spec 20).
*/
export type RoutineId = string;

/**
* Identifies one run of a routine.
*/
export type RoutineRunId = string;

/**
* A group of bots that can message each other and share a folder.
*/
Expand Down Expand Up @@ -353,7 +363,7 @@ export type SenderKind = "owner" | "bot" | "system";
/**
* What a message is for.
*/
export type MessageKind = "note" | "task" | "result" | "system";
export type MessageKind = "note" | "task" | "result" | "system" | "routine";

/**
* Where a delivery is in the courier (spec 9.1).
Expand Down Expand Up @@ -411,6 +421,10 @@ fromBotId: BotId | null, toBotId: BotId, kind: MessageKind, body: string,
* The task this message asks for, answers or reports on.
*/
taskId: TaskId | null,
/**
* The routine that sent it (spec 20.5).
*/
routineId: RoutineId | null,
/**
* Files the owner attached (spec 9.5); empty for everything else.
*/
Expand Down Expand Up @@ -632,6 +646,81 @@ note?: string,
*/
input?: string, };

/**
* When a routine runs (spec 20.2), kept as structure rather than cron text
* so the app can show and edit it without jargon.
*/
export type Schedule = { "kind": "weekly", days: Array<number>, time: string, } | { "kind": "interval", minutes: number, } | { "kind": "cron", expr: string, };

/**
* What happens when a routine's time comes while its last run is still
* waiting or working (spec 20.3).
*/
export type Overlap = "skip" | "queue";

/**
* What happens with times that passed while the daemon was not running
* (spec 20.4).
*/
export type Missed = "run_once" | "skip";

/**
* Where a run is (spec 20.3).
*/
export type RunStatus = "queued" | "done" | "failed" | "skipped";

/**
* Why a run was skipped.
*/
export type SkipReason = "overlap" | "bot_paused" | "missed";

export type RoutineRun = { id: RoutineRunId, routineId: RoutineId,
/**
* The time it was for; the moment of the click for "run now".
*/
scheduledFor: number, status: RunStatus, reason: SkipReason | null,
/**
* For a `missed` skip: how many times passed.
*/
skippedCount: number,
/**
* The message it sent, unless skipped.
*/
messageId: MessageId | null, createdAt: number, finishedAt: number | null, };

export type Routine = { id: RoutineId, botId: BotId, name: string,
/**
* What the bot is asked to do, as the owner wrote it.
*/
prompt: string, schedule: Schedule,
/**
* IANA name, like `America/Sao_Paulo`.
*/
timezone: string, overlap: Overlap, missed: Missed, enabled: boolean,
/**
* Unix ms of the next scheduled time; `null` while disabled.
*/
nextRunAt: number | null,
/**
* The latest run, skipped ones included.
*/
lastRun: RoutineRun | null, createdAt: number, updatedAt: number, archivedAt: number | null, };

export type RoutinesListParams = { botId?: BotId, };

export type RoutinesCreateParams = { botId: BotId, name: string, prompt: string, schedule: Schedule, timezone: string, overlap?: Overlap, missed?: Missed, };

/**
* Fields left out stay as they are.
*/
export type RoutinesUpdateParams = { routineId: RoutineId, name?: string, prompt?: string, schedule?: Schedule, timezone?: string, overlap?: Overlap, missed?: Missed, };

export type RoutinesSetEnabledParams = { routineId: RoutineId, enabled: boolean, };

export type RoutineIdParams = { routineId: RoutineId, };

export type RoutinesRunsParams = { routineId: RoutineId, before?: RoutineRunId, limit?: number, };

/** Params and result of every request method. */
export interface RpcMethods {
"session.hello": { params: HelloParams; result: HelloResult };
Expand Down Expand Up @@ -660,6 +749,13 @@ export interface RpcMethods {
"deliveries.list": { params: DeliveriesListParams; result: Array<Delivery> };
"deliveries.retry": { params: DeliveryIdParams; result: Delivery };
"tasks.list": { params: TasksListParams; result: Array<Task> };
"routines.list": { params: RoutinesListParams; result: Array<Routine> };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
"routines.setEnabled": { params: RoutinesSetEnabledParams; result: Routine };
"routines.runNow": { params: RoutineIdParams; result: RoutineRun };
"routines.archive": { params: RoutineIdParams; result: Routine };
"routines.runs": { params: RoutinesRunsParams; result: Array<RoutineRun> };
}

/** Params of every server notification. */
Expand All @@ -672,6 +768,8 @@ export interface RpcNotifications {
"message.created": Message;
"delivery.changed": Delivery;
"task.changed": Task;
"routine.changed": Routine;
"routine.run": RoutineRun;
}

export const RpcErrorCode = {
Expand Down
41 changes: 40 additions & 1 deletion crates/botloft-core/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl Envelope<'_> {
format!("task {} · {}", task.id, due(task.deadline_at, now_ms))
}
MessageKind::Result => format!("result of task {} · {}", task.id, task.status),
MessageKind::Note | MessageKind::System => {
MessageKind::Note | MessageKind::System | MessageKind::Routine => {
format!("task {} · {}", task.id, task.status)
}
};
Expand Down Expand Up @@ -67,6 +67,28 @@ impl Envelope<'_> {
}
}

/// What a routine's message says (spec 20.5): which routine, the time it
/// is for (absolute: the bot does not know the current time) and that
/// nobody is watching.
#[derive(Debug, Clone, Copy)]
pub struct RoutineEnvelope<'a> {
pub name: &'a str,
/// Local time in the routine's zone, like `2026-10-01 09:00`.
pub scheduled: &'a str,
pub timezone: &'a str,
pub body: &'a str,
}

impl RoutineEnvelope<'_> {
pub fn render(&self) -> String {
format!(
"[botloft] routine \"{}\" · scheduled {} ({})\n\
Nobody is watching live: do the work, then report it in your reply.\n\n{}",
self.name, self.scheduled, self.timezone, self.body
)
}
}

/// 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 {
Expand Down Expand Up @@ -176,4 +198,21 @@ mod tests {
assert_eq!(due(26 * 60 * MINUTE, 0), "due in 26 h");
assert_eq!(due(0, 5 * MINUTE), "overdue");
}

#[test]
fn a_routine_says_what_and_when_and_that_nobody_watches() {
let text = RoutineEnvelope {
name: "Resumo da manhã",
scheduled: "2026-10-01 09:00",
timezone: "America/Sao_Paulo",
body: "Summarize shared/inbox.",
}
.render();
assert_eq!(
text,
"[botloft] routine \"Resumo da manhã\" · scheduled 2026-10-01 09:00 (America/Sao_Paulo)\n\
Nobody is watching live: do the work, then report it in your reply.\n\n\
Summarize shared/inbox."
);
}
}
8 changes: 8 additions & 0 deletions crates/botloft-core/src/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ prefixed_id!(
/// Identifies a file the owner attached to a message.
AttachmentId, "attachment", "att_"
);
prefixed_id!(
/// Identifies a routine (spec 20).
RoutineId, "routine", "rtn_"
);
prefixed_id!(
/// Identifies one run of a routine.
RoutineRunId, "routine run", "rrn_"
);

/// A random version 4 UUID, for Claude Code session ids and the uuid of
/// each message written to a bot (spec 7.3, 9.2).
Expand Down
Loading
Loading