]> git.giacomolli.uk - onechat/commitdiff
Keep the schedules in a file
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Mon, 9 Nov 2026 00:24:44 +0000 (21:24 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Mon, 9 Nov 2026 00:24:44 +0000 (21:24 -0300)
The agent gets schedules: a task with one local time or a cron
expression. agent/schedules.json holds them, and each set and remove
writes it whole with mode 0600 before it returns, so a schedule survives
a restart. The file keeps the last ID, so that an ID is never used
again. A mutex protects the schedules, because the tools change them
from a turn while the runs read them.

A one-time time is a local wall time after now. A time in the hour that
the clock repeats is its first occurrence, as in cron. The cron
expressions are checked at a set and at the start.

A run ends with Done: a one-time schedule goes, and a repeating one gets
the next time after now, so a repeating schedule that missed several
times while the daemon was stopped runs once. The message of a run names
the schedule, gives its time and the task, says when the run is more
than one minute late, and ends with the line about the empty reply, as a
WhatsApp batch does.

Issue: 60e4e4f

schedule.go [new file with mode: 0644]
schedule_test.go [new file with mode: 0644]

diff --git a/schedule.go b/schedule.go
new file mode 100644 (file)
index 0000000..3114ac7
--- /dev/null
@@ -0,0 +1,318 @@
+package main
+
+import (
+       "cmp"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "os"
+       "slices"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       // scheduleFileName is the file of the schedules in the directory of the
+       // agent.
+       scheduleFileName = "schedules.json"
+
+       // scheduleTextBytes is the most bytes of the text of a schedule.
+       scheduleTextBytes = 4096
+
+       // scheduleAtLayout is the layout of the time of a one-time schedule.
+       scheduleAtLayout = "2006-01-02 15:04"
+
+       // scheduleTimeLayout is the layout of the times in the answers of the
+       // tools and in the log.
+       scheduleTimeLayout = "Monday 2006-01-02 15:04 -07:00"
+
+       // scheduleLate is how long after its time a run is late.
+       scheduleLate = time.Minute
+
+       // scheduleEmptyReply is the last line of the message of a run.
+       scheduleEmptyReply = "If nothing in this task needs the user, end your turn with an empty reply."
+
+       // scheduleLateLine tells the agent that a run is late.
+       scheduleLateLine = "This run is late: the daemon was stopped at that time."
+)
+
+// openSchedules reads the schedules of the file at path. A missing file holds
+// none. The clock gives the time and the time zone of the schedules.
+func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
+       s := &Schedules{
+               path:  path,
+               clock: clock,
+       }
+
+       data, err := os.ReadFile(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return s, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read schedules: %w", err)
+       }
+
+       err = json.Unmarshal(data, &s.file)
+       if err != nil {
+               return nil, fmt.Errorf("%s: %w", path, err)
+       }
+
+       for _, schedule := range s.file.Schedules {
+               if schedule.Cron == "" {
+                       continue
+               }
+
+               _, err = scheduleParseCron(schedule.Cron)
+               if err != nil {
+                       return nil, fmt.Errorf("%s: schedule %d: %w", path, schedule.ID, err)
+               }
+       }
+
+       return s, nil
+}
+
+// Schedules holds the schedules of the agent: tasks that start a turn at one
+// time or at the times of a cron expression. Each change writes the file whole
+// before it returns, so a schedule survives a restart. The mutex protects the
+// schedules, because the tools change them from a turn while the runs read
+// them.
+type Schedules struct {
+       path  string
+       clock func() time.Time
+
+       mutex sync.Mutex
+       file  ScheduleFile
+}
+
+// Set adds a schedule with text and returns the answer of the tool. Exactly one
+// of at and cron is not nil. at is a local time in scheduleAtLayout after now,
+// and it goes through scheduleWallTime, so a time in the hour that the clock
+// repeats is its first occurrence. cron is an expression of scheduleParseCron.
+func (s *Schedules) Set(text string, at, cron *string) (string, error) {
+       text = strings.TrimSpace(text)
+       if text == "" || len(text) > scheduleTextBytes {
+               return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
+       }
+
+       now := s.clock()
+       schedule := Schedule{
+               Text: text,
+       }
+
+       switch {
+       case (at == nil) == (cron == nil):
+               return "", errors.New("give either at or cron, and null for the other")
+       case at != nil:
+               parsed, err := time.Parse(scheduleAtLayout, *at)
+               if err != nil {
+                       return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
+               }
+
+               schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
+               if !schedule.Due.After(now) {
+                       return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
+               }
+       default:
+               parsed, err := scheduleParseCron(*cron)
+               if err != nil {
+                       return "", err
+               }
+
+               next, found := parsed.Next(now)
+               if !found {
+                       return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
+               }
+
+               schedule.Cron = parsed.String()
+               schedule.Due = next
+       }
+
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       s.file.Next++
+       schedule.ID = s.file.Next
+       s.file.Schedules = append(s.file.Schedules, schedule)
+       err := s.save()
+       if err != nil {
+               return "", err
+       }
+
+       return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil
+}
+
+// List returns the answer of the tool: one line for each schedule, by due time,
+// with its ID, its next time, its cron expression or once, and its text on one
+// line.
+func (s *Schedules) List() string {
+       location := s.clock().Location()
+       schedules := s.sorted()
+       if len(schedules) == 0 {
+               return "No schedules."
+       }
+
+       lines := make([]string, 0, len(schedules))
+       for _, schedule := range schedules {
+               lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " "))
+       }
+
+       return strings.Join(lines, "\n")
+}
+
+// Remove removes the schedule id and returns the answer of the tool.
+func (s *Schedules) Remove(id int) (string, error) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool {
+               return schedule.ID == id
+       })
+
+       if index < 0 {
+               return "", fmt.Errorf("no schedule %d", id)
+       }
+
+       s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+       err := s.save()
+       if err != nil {
+               return "", err
+       }
+
+       return fmt.Sprintf("Schedule %d removed.", id), nil
+}
+
+// Due returns the schedules whose time is not after now, by due time.
+func (s *Schedules) Due(now time.Time) []Schedule {
+       var due []Schedule
+       for _, schedule := range s.sorted() {
+               if !schedule.Due.After(now) {
+                       due = append(due, schedule)
+               }
+       }
+
+       return due
+}
+
+// Done ends the run of schedule at now: a one-time schedule goes, and a
+// repeating schedule gets the next time after now. So a repeating schedule that
+// missed several times runs once. When a remove took the schedule first, Done
+// changes nothing.
+func (s *Schedules) Done(schedule Schedule, now time.Time) error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       index := slices.IndexFunc(s.file.Schedules, func(held Schedule) bool {
+               return held.ID == schedule.ID
+       })
+
+       if index < 0 {
+               return nil
+       }
+
+       if schedule.Cron == "" {
+               s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+
+               return s.save()
+       }
+
+       cron, err := scheduleParseCron(schedule.Cron)
+       if err != nil {
+               return fmt.Errorf("schedule %d: %w", schedule.ID, err)
+       }
+
+       next, found := cron.Next(now)
+       if !found {
+               s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
+
+               return s.save()
+       }
+
+       s.file.Schedules[index].Due = next
+
+       return s.save()
+}
+
+// Wait returns how long after now the first schedule is due, or false when
+// there is no schedule.
+func (s *Schedules) Wait(now time.Time) (time.Duration, bool) {
+       schedules := s.sorted()
+       if len(schedules) == 0 {
+               return 0, false
+       }
+
+       return max(schedules[0].Due.Sub(now), 0), true
+}
+
+// sorted returns a copy of the schedules by due time, then by ID.
+func (s *Schedules) sorted() []Schedule {
+       s.mutex.Lock()
+       schedules := slices.Clone(s.file.Schedules)
+       s.mutex.Unlock()
+
+       slices.SortFunc(schedules, func(a, b Schedule) int {
+               return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID))
+       })
+
+       return schedules
+}
+
+// save writes the file whole, with mode 0600. The caller holds the mutex.
+func (s *Schedules) save() error {
+       data, err := json.Marshal(s.file)
+       if err == nil {
+               err = writeFileAtomic(s.path, append(data, '\n'))
+       }
+
+       if err != nil {
+               return fmt.Errorf("save schedules: %w", err)
+       }
+
+       return nil
+}
+
+// ScheduleFile is the content of the file of the schedules: the ID of the last
+// schedule, so that an ID is never used again, and the schedules.
+type ScheduleFile struct {
+       Next      int        `json:"next"`
+       Schedules []Schedule `json:"schedules"`
+}
+
+// Schedule is one schedule: its ID, the task for the agent, the cron expression
+// of a repeating schedule, empty for a one-time schedule, and its next time.
+type Schedule struct {
+       ID   int       `json:"id"`
+       Text string    `json:"text"`
+       Cron string    `json:"cron"`
+       Due  time.Time `json:"due"`
+}
+
+// Message returns the text of a run at now for the log, with the times in the
+// location of now. The first line starts with [Schedule ID] and gives the due
+// time and once or the cron expression. Then come the text, the line about a
+// late run when now is more than scheduleLate after the due time, and the line
+// about the empty reply.
+func (s Schedule) Message(now time.Time) string {
+       lines := []string{
+               "[Schedule " + strconv.Itoa(s.ID) + "] " + s.Due.In(now.Location()).Format(scheduleTimeLayout) + ", " + s.repeats(),
+               s.Text,
+       }
+
+       if now.Sub(s.Due) > scheduleLate {
+               lines = append(lines, scheduleLateLine)
+       }
+
+       return strings.Join(append(lines, scheduleEmptyReply), "\n")
+}
+
+// repeats returns once for a one-time schedule, or else repeats and the cron
+// expression.
+func (s Schedule) repeats() string {
+       if s.Cron == "" {
+               return "once"
+       }
+
+       return "repeats " + s.Cron
+}
diff --git a/schedule_test.go b/schedule_test.go
new file mode 100644 (file)
index 0000000..5ef174b
--- /dev/null
@@ -0,0 +1,247 @@
+package main
+
+import (
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestSchedulesSet(t *testing.T) {
+       testCases := []struct {
+               name   string
+               text   string
+               at     *string
+               cron   *string
+               answer string
+               listed string
+       }{
+               {
+                       name:   "a one-time schedule at a local time",
+                       text:   "Remind the user to drink water.",
+                       at:     new("2026-11-09 09:00"),
+                       answer: "Schedule 1 set. Next: Monday 2026-11-09 09:00 -03:00.",
+                       listed: "1 | Monday 2026-11-09 09:00 -03:00 | once | Remind the user to drink water.",
+               },
+               {
+                       name:   "a repeating schedule at the next match",
+                       text:   "Tell the user\nthe time.",
+                       cron:   new("*/5  * * * *"),
+                       answer: "Schedule 1 set. Next: Sunday 2026-11-08 20:05 -03:00.",
+                       listed: "1 | Sunday 2026-11-08 20:05 -03:00 | */5 * * * * | Tell the user the time.",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       schedules, _ := scheduleTestOpen(t)
+
+                       answer, err := schedules.Set(tc.text, tc.at, tc.cron)
+                       require.NoError(t, err)
+                       require.Equal(t, tc.answer, answer)
+                       require.Equal(t, tc.listed, schedules.List())
+               })
+       }
+}
+
+func TestSchedulesSetRefuses(t *testing.T) {
+       testCases := []struct {
+               name    string
+               text    string
+               at      *string
+               cron    *string
+               message string
+       }{
+               {
+                       name:    "an empty text",
+                       text:    " \n",
+                       at:      new("2026-11-09 09:00"),
+                       message: "the text must have 1 to 4096 bytes",
+               },
+               {
+                       name:    "a text that is too long",
+                       text:    strings.Repeat("x", scheduleTextBytes+1),
+                       at:      new("2026-11-09 09:00"),
+                       message: "the text must have 1 to 4096 bytes",
+               },
+               {
+                       name:    "neither at nor cron",
+                       text:    "Tell the user the time.",
+                       message: "give either at or cron, and null for the other",
+               },
+               {
+                       name:    "both at and cron",
+                       text:    "Tell the user the time.",
+                       at:      new("2026-11-09 09:00"),
+                       cron:    new("0 9 * * *"),
+                       message: "give either at or cron, and null for the other",
+               },
+               {
+                       name:    "a time in another layout",
+                       text:    "Tell the user the time.",
+                       at:      new("2026-11-09T09:00:00-03:00"),
+                       message: "at must be a local time such as 2026-11-08 20:00",
+               },
+               {
+                       name:    "a time that is past",
+                       text:    "Tell the user the time.",
+                       at:      new("2026-11-08 20:00"),
+                       message: "the time Sunday 2026-11-08 20:00 -03:00 is not after now, Sunday 2026-11-08 20:00 -03:00",
+               },
+               {
+                       name:    "runs less than 5 minutes apart",
+                       text:    "Tell the user the time.",
+                       cron:    new("* * * * *"),
+                       message: "at least 5 minutes apart",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       schedules, path := scheduleTestOpen(t)
+
+                       _, err := schedules.Set(tc.text, tc.at, tc.cron)
+                       require.ErrorContains(t, err, tc.message)
+                       require.NoFileExists(t, path)
+               })
+       }
+}
+
+func TestSchedulesSurviveARestart(t *testing.T) {
+       schedules, path := scheduleTestOpen(t)
+
+       _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+       require.NoError(t, err)
+
+       _, err = schedules.Set("Tell the user the news.", nil, new("0 8 * * 1-5"))
+       require.NoError(t, err)
+
+       _, err = schedules.Remove(1)
+       require.NoError(t, err)
+
+       info, err := os.Stat(path)
+       require.NoError(t, err)
+       require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+       reopened, err := openSchedules(path, schedules.clock)
+       require.NoError(t, err)
+       require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
+
+       answer, err := reopened.Set("Remind the user to call.", new("2026-11-08 21:00"), nil)
+       require.NoError(t, err)
+       require.Equal(t, "Schedule 3 set. Next: Sunday 2026-11-08 21:00 -03:00.", answer)
+}
+
+func TestSchedulesRemove(t *testing.T) {
+       schedules, _ := scheduleTestOpen(t)
+
+       _, err := schedules.Set("Remind the user to drink water.", new("2026-11-09 09:00"), nil)
+       require.NoError(t, err)
+
+       answer, err := schedules.Remove(1)
+       require.NoError(t, err)
+       require.Equal(t, "Schedule 1 removed.", answer)
+       require.Equal(t, "No schedules.", schedules.List())
+
+       _, err = schedules.Remove(1)
+       require.EqualError(t, err, "no schedule 1")
+}
+
+func TestSchedulesDone(t *testing.T) {
+       schedules, _ := scheduleTestOpen(t)
+       start := schedules.clock()
+
+       _, err := schedules.Set("Remind the user to drink water.", new("2026-11-08 21:00"), nil)
+       require.NoError(t, err)
+
+       _, err = schedules.Set("Tell the user the time.", nil, new("0 * * * *"))
+       require.NoError(t, err)
+
+       wait, found := schedules.Wait(start)
+       require.True(t, found)
+       require.Equal(t, time.Hour, wait)
+
+       // The daemon was stopped for three hours, so each schedule missed its
+       // times and runs once.
+       now := start.Add(3*time.Hour + 30*time.Minute)
+       due := schedules.Due(now)
+       require.Len(t, due, 2)
+       for _, schedule := range due {
+               require.NoError(t, schedules.Done(schedule, now))
+       }
+
+       require.Empty(t, schedules.Due(now))
+       require.Equal(t, "2 | Monday 2026-11-09 00:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+
+       _, err = schedules.Remove(2)
+       require.NoError(t, err)
+       require.NoError(t, schedules.Done(due[1], now))
+       require.Equal(t, "No schedules.", schedules.List())
+
+       _, found = schedules.Wait(now)
+       require.False(t, found)
+}
+
+func TestScheduleMessage(t *testing.T) {
+       zone := time.FixedZone("", -3*60*60)
+       due := time.Date(2026, time.November, 9, 9, 0, 0, 0, zone)
+       testCases := []struct {
+               name     string
+               schedule Schedule
+               now      time.Time
+               message  string
+       }{
+               {
+                       name: "a one-time run on time",
+                       schedule: Schedule{
+                               ID:   3,
+                               Text: "Remind the user to drink water.",
+                               Due:  due,
+                       },
+                       now:     due.Add(time.Second),
+                       message: "[Schedule 3] Monday 2026-11-09 09:00 -03:00, once\nRemind the user to drink water.\n" + scheduleEmptyReply,
+               },
+               {
+                       name: "a late repeating run",
+                       schedule: Schedule{
+                               ID:   4,
+                               Text: "Tell the user the news.",
+                               Cron: "0 9 * * 1-5",
+                               Due:  due,
+                       },
+                       now:     due.Add(2 * time.Hour),
+                       message: "[Schedule 4] Monday 2026-11-09 09:00 -03:00, repeats 0 9 * * 1-5\nTell the user the news.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       require.Equal(t, tc.message, tc.schedule.Message(tc.now))
+               })
+       }
+}
+
+func TestOpenSchedulesRefusesABadCron(t *testing.T) {
+       path := filepath.Join(t.TempDir(), scheduleFileName)
+       require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
+
+       _, err := openSchedules(path, time.Now)
+       require.ErrorContains(t, err, "schedule 1: the runs of")
+}
+
+// scheduleTestOpen opens the schedules of a new file, with a clock at Sunday
+// 2026-11-08 20:00 -03:00, and returns them and the path of the file.
+func scheduleTestOpen(t *testing.T) (*Schedules, string) {
+       path := filepath.Join(t.TempDir(), scheduleFileName)
+       clock := &TestClock{
+               now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
+       }
+
+       schedules, err := openSchedules(path, clock.Now)
+       require.NoError(t, err)
+
+       return schedules, path
+}