]> git.giacomolli.uk - onechat/blobdiff - schedule.go
Rewrite the README as a user guide
[onechat] / schedule.go
index f3dcfe247d720b08d6d60f9b2ea27fe15c52905f..b3a65ea94193a6431693313ca6f5833d836ccefb 100644 (file)
@@ -6,6 +6,7 @@ import (
        "encoding/json"
        "errors"
        "fmt"
+       "log/slog"
        "os"
        "slices"
        "strconv"
@@ -48,11 +49,15 @@ const (
 )
 
 // 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) {
+// none. The owner names its agent. Schedules without an owner take this name. A
+// different stored owner is an error. The clock gives the time and the time
+// zone of the schedules, and logger gets one line for each run.
+func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logger) (*Schedules, error) {
        s := &Schedules{
                path:    path,
+               owner:   owner,
                clock:   clock,
+               logger:  logger,
                check:   scheduleCheck,
                changed: make(chan struct{}, 1),
        }
@@ -71,7 +76,18 @@ func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
                return nil, fmt.Errorf("%s: %w", path, err)
        }
 
-       for _, schedule := range s.file.Schedules {
+       changed := false
+       for i := range s.file.Schedules {
+               schedule := &s.file.Schedules[i]
+               if schedule.Owner == "" {
+                       schedule.Owner = owner
+                       changed = true
+               }
+
+               if schedule.Owner != owner {
+                       return nil, fmt.Errorf("schedule %d belongs to %q, not %q", schedule.ID, schedule.Owner, owner)
+               }
+
                if schedule.Cron == "" {
                        continue
                }
@@ -82,6 +98,13 @@ func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
                }
        }
 
+       if changed {
+               err = s.save()
+               if err != nil {
+                       return nil, fmt.Errorf("record schedule owner: %w", err)
+               }
+       }
+
        return s, nil
 }
 
@@ -92,7 +115,9 @@ func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
 // changed tells Run that a set or a remove changed the times.
 type Schedules struct {
        path    string
+       owner   string
        clock   func() time.Time
+       logger  *slog.Logger
        check   time.Duration
        changed chan struct{}
 
@@ -111,8 +136,11 @@ func (s *Schedules) Run(ctx context.Context, l *TurnLoop) error {
                        return err
                }
 
-               var timer *time.Timer
-               var timeout <-chan time.Time
+               var (
+                       timer   *time.Timer
+                       timeout <-chan time.Time
+               )
+
                wait, found := s.Wait(s.clock())
                if found {
                        timer = time.NewTimer(min(wait, s.check))
@@ -144,7 +172,8 @@ func (s *Schedules) Set(text string, at, cron *string) (string, error) {
 
        now := s.clock()
        schedule := Schedule{
-               Text: text,
+               Text:  text,
+               Owner: s.owner,
        }
 
        switch {
@@ -244,10 +273,18 @@ func (s *Schedules) Remove(id int) (string, error) {
 // deliver logs the message of each due schedule in l, in due order, as a work
 // message of scheduleSource, and wakes the loop once with all of them. A
 // schedule ends its run with Done after its message is durable in the log. A
-// crash between the two makes the run repeat once.
+// crash between the two makes the run repeat once. Each run logs one line with
+// the ID of the schedule and, for a late run, how late it is. The line holds no
+// text of the schedule.
 func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
+       if l.agent.config.Name != s.owner {
+               return fmt.Errorf("schedule owner %q does not match agent %q", s.owner, l.agent.config.Name)
+       }
+
        now := s.clock()
+
        var turns []Message
+
        for _, schedule := range s.Due(now) {
                message, err := l.Work(scheduleSource, schedule.Message(now), "")
                if err != nil {
@@ -270,6 +307,13 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
                        return err
                }
 
+               attrs := []any{"id", schedule.ID}
+               if schedule.late(now) {
+                       attrs = append(attrs, "late", now.Sub(schedule.Due))
+               }
+
+               s.logger.Info("schedule ran", attrs...)
+
                turns = append(turns, message)
        }
 
@@ -281,6 +325,7 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
 // 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)
@@ -351,6 +396,7 @@ func (s *Schedules) change() {
 // 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()
 
@@ -382,13 +428,15 @@ type ScheduleFile struct {
        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.
+// Schedule is one schedule: its owner, 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"`
+       Owner string    `json:"owner"`
+       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
@@ -402,13 +450,19 @@ func (s Schedule) Message(now time.Time) string {
                s.Text,
        }
 
-       if now.Sub(s.Due) > scheduleLate {
+       if s.late(now) {
                lines = append(lines, scheduleLateLine)
        }
 
        return strings.Join(append(lines, scheduleEmptyReply), "\n")
 }
 
+// late tells if a run at now is late: more than scheduleLate after the due
+// time.
+func (s Schedule) late(now time.Time) bool {
+       return now.Sub(s.Due) > scheduleLate
+}
+
 // repeats returns once for a one-time schedule, or else repeats and the cron
 // expression.
 func (s Schedule) repeats() string {