feat: add persistent cron-based recurring reminders

Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:33:47 +01:00
parent 58ba01f20f
commit e87380775f
6 changed files with 332 additions and 39 deletions

View File

@@ -415,13 +415,14 @@ export class BrokerClient {
// --- Scheduled messages ---
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt } or null on timeout. */
/** Schedule a message for future delivery. Returns { scheduledId, deliverAt, cron? } or null on timeout. */
async scheduleMessage(
to: string,
message: string,
deliverAt: number,
isReminder = false,
): Promise<{ scheduledId: string; deliverAt: number } | null> {
cron?: string,
): Promise<{ scheduledId: string; deliverAt: number; cron?: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
const reqId = this.makeReqId();
@@ -434,6 +435,7 @@ export class BrokerClient {
message,
deliverAt,
...(isReminder ? { subtype: "reminder" } : {}),
...(cron ? { cron, recurring: true } : {}),
_reqId: reqId,
}));
});
@@ -1123,6 +1125,7 @@ export class BrokerClient {
this.resolveFromMap(this.scheduledAckResolvers, msgReqId, {
scheduledId: String(msg.scheduledId ?? ""),
deliverAt: Number(msg.deliverAt ?? 0),
...(msg.cron ? { cron: String(msg.cron) } : {}),
});
return;
}