From: Anderson A. Giacomolli Date: Mon, 9 Nov 2026 00:24:44 +0000 (-0300) Subject: Keep the schedules in a file X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/396af2e18eb02d4ec1e35edbc62f01e84bae2b46 Keep the schedules in a file 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 --- diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..3114ac7 --- /dev/null +++ b/schedule.go @@ -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 index 0000000..5ef174b --- /dev/null +++ b/schedule_test.go @@ -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 +}