reminders: add recurring reminder support

Add cron expression support for recurring reminders using robfig/cron/v3.

Changes:
- Migration #2: ALTER TABLE reminders ADD COLUMN cron TEXT + next_fire_ts INTEGER
- Reminder struct: add Cron and NextFireTs fields
- scanReminder helper extracts full row including nullable cron
- CreateReminder: accept optional cron param, store next_fire_ts = fire_ts
- DueReminders: query on next_fire_ts instead of fire_ts
- RescheduleReminder: new method — parse cron, compute next fire, update
  next_fire_ts or mark fired if no more valid times
- Dispatcher: call RescheduleReminder for cron reminders, MarkReminder for
  one-shots (preserving existing behavior for ID=0 digest skip)
- ReminderCompleter interface: add RescheduleReminder method
- storeAPI adapter: forward RescheduleReminder
- All callers updated: CreateReminder signature includes cron param
- Tests: TestRecurringReminder (store), TestDispatchRecurringReminderReschedules
- Existing tests updated for new signature
This commit is contained in:
kami
2026-07-05 11:46:12 +04:00
parent 6b80fd0c0f
commit 1eca17f37b
16 changed files with 200 additions and 45 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ Notes:
| # | Task | Commit | Status |
|---|------|--------|--------|
| 12 | **Recurring reminders** — cron expression in reminder table. New `cron` column, `next_fire_ts` computed from cron. "trash every tuesday" | — | pending |
| 13 | **Capability model**`scope:name` instead of flat `tool→enabled`. Schema: add `scope` to tools table. Backward-compat: bare name = `homelab:name` | — | pending |
| 13 | **Capability model**`scope` column on tools table, migration, UI, tests | 6b80fd0 | done |
| 14 | **Notification batching / digest mode** — morning/evening rollup instead of per-event nudges. Configurable window, accumulated messages in one delivery | — | pending |
| 15 | **Rule trace / explanation engine**`why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending |
| 16 | **Backup/restore automation** — script: `cp` the encrypted DB + re-encrypt-verify + restore flow | — | pending |
+1 -1
View File
@@ -150,7 +150,7 @@ func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`); err != nil {
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`, ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
sink := &fakeSink{}
+1 -1
View File
@@ -354,7 +354,7 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return "не получилось разобрать время напоминания."
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload); err != nil {
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
}
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/yalue/onnxruntime_go v1.31.0 // indirect
golang.org/x/text v0.3.0 // indirect
modernc.org/libc v1.55.3 // indirect
+2
View File
@@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+1 -1
View File
@@ -335,7 +335,7 @@ func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Dur
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
return ipc.Presence{}, nil
}
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _ string) (int64, error) {
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ string) (int64, error) {
return 1, nil
}
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
+14 -6
View File
@@ -20,11 +20,13 @@ type NudgeRecorder interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
}
// ReminderCompleter — the seam the store implements. a reminder fires once:
// pending → fired after successful delivery. a failed send does NOT mark the
// reminder fired (it stays pending; the next tick re-delivers).
// ReminderCompleter — the seam the store implements. For one-shot reminders:
// pending → fired after successful delivery. For recurring reminders (with
// cron): reschedule after successful delivery. A failed send does NOT mark or
// reschedule it (it stays pending; the next tick re-delivers).
type ReminderCompleter interface {
MarkReminder(ctx context.Context, id int64, status string) error
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
}
// PhrasedNudge — the phraser module's output for a nudge. the phraser (LFM
@@ -184,11 +186,17 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
}
if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB so
// MarkReminder would fail with ErrReminderNotFound. The originals
// MarkReminder/RescheduleReminder would fail. The originals
// were already marked fired by collapseReminders in gather.go.
if rd.Reminder.ID != 0 {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
return out, fmt.Errorf("mark reminder fired: %w", err)
if rd.Reminder.Cron != "" {
if err := d.cfg.Reminders.RescheduleReminder(ctx, rd.Reminder.ID, now); err != nil {
return out, fmt.Errorf("reschedule reminder %d: %w", rd.Reminder.ID, err)
}
} else {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
return out, fmt.Errorf("mark reminder fired: %w", err)
}
}
}
}
+37 -1
View File
@@ -60,7 +60,8 @@ type fakeReminderCompleter struct {
id int64
status string
}
err error
rescheduled []int64
err error
}
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
@@ -74,6 +75,14 @@ func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status
return nil
}
func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64, _ time.Time) error {
if f.err != nil {
return f.err
}
f.rescheduled = append(f.rescheduled, id)
return nil
}
type fakeAck struct {
acked map[string]bool
lastSent map[string]time.Time
@@ -599,3 +608,30 @@ func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) {
t.Fatalf("nil telegram/ack: want nil, got %+v", out)
}
}
func TestDispatchRecurringReminderReschedules(t *testing.T) {
voice := &fakeSink{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Voice: voice, Reminders: rc})
rd := loop.ReminderDecision{
Reminder: store.Reminder{ID: 7, Cron: "0 9 * * *", Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Present},
}
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: rd, Body: "daily standup", Summary: "standup",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 dispatch, got %d", len(out))
}
// Should RescheduleReminder, not MarkReminder
if len(rc.rescheduled) != 1 || rc.rescheduled[0] != 7 {
t.Fatalf("want rescheduled 7, got %+v", rc.rescheduled)
}
if len(rc.marked) != 0 {
t.Fatalf("recurring reminder should not be marked fired: %+v", rc.marked)
}
}
+10 -7
View File
@@ -51,13 +51,15 @@ type Note struct {
Score float64 `json:"score"`
}
// Reminder — user-stated future intent; fires once.
// Reminder — user-stated future intent; fires once or recurring (if cron set).
type Reminder struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"`
Payload string `json:"payload"`
Status string `json:"status"` // pending|fired|cancelled
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"`
NextFireTs time.Time `json:"next_fire_ts"`
Payload string `json:"payload"`
Status string `json:"status"` // pending|fired|cancelled
Cron string `json:"cron"`
}
// Presence — the read the phraser / delivery modules need to decide channel
@@ -137,6 +139,7 @@ type queryNotesReq struct {
type createReminderReq struct {
Fire time.Time `json:"fire"`
Payload string `json:"payload"`
Cron string `json:"cron"`
}
type recordNudgeReq struct {
Rule string `json:"rule"`
@@ -212,7 +215,7 @@ type CoreAPI interface {
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, error)
CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error)
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
+2 -2
View File
@@ -237,9 +237,9 @@ func (c *Client) Presence(ctx context.Context) (Presence, error) {
return p, nil
}
func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
var r idResp
if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload}, &r); err != nil {
if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload, Cron: cron}, &r); err != nil {
return 0, err
}
return r.ID, nil
+1 -1
View File
@@ -311,7 +311,7 @@ func TestClient_E2E(t *testing.T) {
}
// reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState.
rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`)
rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`, "")
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
+7 -3
View File
@@ -68,8 +68,8 @@ func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
}
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload)
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err)
}
@@ -77,6 +77,10 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err)
@@ -457,7 +461,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload)
id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron)
return marshalResult(idResp{ID: id}), err
case MethodMarkReminder:
+3 -1
View File
@@ -17,7 +17,9 @@ import (
// `ALTER TABLE ...;`, // #1
// }
var migrations = []string{
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`,
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
`ALTER TABLE reminders ADD COLUMN cron TEXT;
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
}
// migrate applies every migration with a number greater than the DB's current
+70 -18
View File
@@ -6,16 +6,19 @@ import (
"errors"
"fmt"
"time"
"github.com/robfig/cron/v3"
)
// Reminder — user-stated future intent. fires once. relative→absolute happens
// at capture ("in 4h" → store now+4h, never the string).
// Reminder — user-stated future intent. fires once or recurring (if cron set).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
Payload string // raw json
Status string // pending | fired | cancelled
NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
}
var (
@@ -23,14 +26,35 @@ var (
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
func scanReminder(sc scanner) (Reminder, error) {
var r Reminder
var created, fire, nextFire int64
var cron *string
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
return Reminder{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
r.NextFireTs = time.UnixMilli(nextFire).UTC()
if cron != nil {
r.Cron = *cron
}
return r, nil
}
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
// We do not accept strings here. cron is a cron expression for recurring
// reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
now.UnixMilli(), fire.UnixMilli(), payload)
`INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
@@ -41,27 +65,24 @@ func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload stri
return id, nil
}
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, payload, status
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = 'pending' AND fire_ts <= ?
ORDER BY fire_ts ASC`, now.UnixMilli())
WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY next_fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
var r Reminder
var created, fire int64
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
out = append(out, r)
}
return out, rows.Err()
@@ -87,4 +108,35 @@ func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}
// RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If no more valid fire times exist, marks it fired.
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
row := s.db.QueryRowContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders WHERE id = ?`, id)
r, err := scanReminder(row)
if err != nil {
return err
}
if r.Cron == "" {
return fmt.Errorf("%w: not a recurring reminder", ErrReminderState)
}
if r.Status != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status)
}
sched, err := cron.ParseStandard(r.Cron)
if err != nil {
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
}
next := sched.Next(r.NextFireTs.Add(time.Minute))
if next.IsZero() || next.Before(now) {
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
return err
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id)
return err
}
+1 -1
View File
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS facts (
CREATE INDEX IF NOT EXISTS idx_facts_key_ts ON facts (key, ts DESC);
CREATE INDEX IF NOT EXISTS idx_facts_voids ON facts (voids_id);
-- reminders — user intent, fires once.
-- reminders — user intent, fires once or recurring (if cron set).
-- relative→absolute happens at capture ("in 4h" → store now+4h, never the string).
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+48 -1
View File
@@ -166,7 +166,7 @@ func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
ctx := context.Background()
// capture path (router) converts "in 4h" → absolute. store just takes fire_ts.
fire := time.Now().UTC().Add(4 * time.Hour)
id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`)
id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`, "")
if err != nil {
t.Fatal(err)
}
@@ -191,6 +191,53 @@ func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
}
}
func TestRecurringReminder(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)
// Create a daily recurring reminder at 9:00
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
id, err := s.CreateReminder(ctx, fire, `{"text":"daily standup"}`, "0 9 * * *")
if err != nil {
t.Fatal(err)
}
// Not due yet (at 8:00, next_fire_ts = 9:00)
if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 0 {
t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
}
// Due at 9:00
due, err := s.DueReminders(ctx, fire.Add(time.Second))
if err != nil || len(due) != 1 || due[0].ID != id {
t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
}
if due[0].Cron != "0 9 * * *" {
t.Fatalf("cron: want %q, got %q", "0 9 * * *", due[0].Cron)
}
// Reschedule: next fire should be tomorrow 9:00
if err := s.RescheduleReminder(ctx, id, fire); err != nil {
t.Fatalf("RescheduleReminder: %v", err)
}
tomorrow := fire.Add(24 * time.Hour)
due, err = s.DueReminders(ctx, tomorrow.Add(time.Second))
if err != nil || len(due) != 1 || due[0].ID != id {
t.Fatalf("after reschedule: want 1 due (%d), got %d (%v)", id, len(due), err)
}
// Mark non-recurring reminder → ErrReminderState
_, err = s.CreateReminder(ctx, fire, `{"text":"one-shot"}`, "")
if err != nil {
t.Fatal(err)
}
// The last id is id+1
if err := s.RescheduleReminder(ctx, id+1, fire); !errors.Is(err, ErrReminderState) {
t.Fatalf("reschedule one-shot: want ErrReminderState, got %v", err)
}
}
func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()