]> git.giacomolli.uk - onechat/blob - schedule.go
Keep the schedules in a file
[onechat] / schedule.go
1 package main
2
3 import (
4         "cmp"
5         "encoding/json"
6         "errors"
7         "fmt"
8         "os"
9         "slices"
10         "strconv"
11         "strings"
12         "sync"
13         "time"
14 )
15
16 const (
17         // scheduleFileName is the file of the schedules in the directory of the
18         // agent.
19         scheduleFileName = "schedules.json"
20
21         // scheduleTextBytes is the most bytes of the text of a schedule.
22         scheduleTextBytes = 4096
23
24         // scheduleAtLayout is the layout of the time of a one-time schedule.
25         scheduleAtLayout = "2006-01-02 15:04"
26
27         // scheduleTimeLayout is the layout of the times in the answers of the
28         // tools and in the log.
29         scheduleTimeLayout = "Monday 2006-01-02 15:04 -07:00"
30
31         // scheduleLate is how long after its time a run is late.
32         scheduleLate = time.Minute
33
34         // scheduleEmptyReply is the last line of the message of a run.
35         scheduleEmptyReply = "If nothing in this task needs the user, end your turn with an empty reply."
36
37         // scheduleLateLine tells the agent that a run is late.
38         scheduleLateLine = "This run is late: the daemon was stopped at that time."
39 )
40
41 // openSchedules reads the schedules of the file at path. A missing file holds
42 // none. The clock gives the time and the time zone of the schedules.
43 func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
44         s := &Schedules{
45                 path:  path,
46                 clock: clock,
47         }
48
49         data, err := os.ReadFile(path)
50         if errors.Is(err, os.ErrNotExist) {
51                 return s, nil
52         }
53
54         if err != nil {
55                 return nil, fmt.Errorf("read schedules: %w", err)
56         }
57
58         err = json.Unmarshal(data, &s.file)
59         if err != nil {
60                 return nil, fmt.Errorf("%s: %w", path, err)
61         }
62
63         for _, schedule := range s.file.Schedules {
64                 if schedule.Cron == "" {
65                         continue
66                 }
67
68                 _, err = scheduleParseCron(schedule.Cron)
69                 if err != nil {
70                         return nil, fmt.Errorf("%s: schedule %d: %w", path, schedule.ID, err)
71                 }
72         }
73
74         return s, nil
75 }
76
77 // Schedules holds the schedules of the agent: tasks that start a turn at one
78 // time or at the times of a cron expression. Each change writes the file whole
79 // before it returns, so a schedule survives a restart. The mutex protects the
80 // schedules, because the tools change them from a turn while the runs read
81 // them.
82 type Schedules struct {
83         path  string
84         clock func() time.Time
85
86         mutex sync.Mutex
87         file  ScheduleFile
88 }
89
90 // Set adds a schedule with text and returns the answer of the tool. Exactly one
91 // of at and cron is not nil. at is a local time in scheduleAtLayout after now,
92 // and it goes through scheduleWallTime, so a time in the hour that the clock
93 // repeats is its first occurrence. cron is an expression of scheduleParseCron.
94 func (s *Schedules) Set(text string, at, cron *string) (string, error) {
95         text = strings.TrimSpace(text)
96         if text == "" || len(text) > scheduleTextBytes {
97                 return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
98         }
99
100         now := s.clock()
101         schedule := Schedule{
102                 Text: text,
103         }
104
105         switch {
106         case (at == nil) == (cron == nil):
107                 return "", errors.New("give either at or cron, and null for the other")
108         case at != nil:
109                 parsed, err := time.Parse(scheduleAtLayout, *at)
110                 if err != nil {
111                         return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
112                 }
113
114                 schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
115                 if !schedule.Due.After(now) {
116                         return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
117                 }
118         default:
119                 parsed, err := scheduleParseCron(*cron)
120                 if err != nil {
121                         return "", err
122                 }
123
124                 next, found := parsed.Next(now)
125                 if !found {
126                         return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
127                 }
128
129                 schedule.Cron = parsed.String()
130                 schedule.Due = next
131         }
132
133         s.mutex.Lock()
134         defer s.mutex.Unlock()
135
136         s.file.Next++
137         schedule.ID = s.file.Next
138         s.file.Schedules = append(s.file.Schedules, schedule)
139         err := s.save()
140         if err != nil {
141                 return "", err
142         }
143
144         return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil
145 }
146
147 // List returns the answer of the tool: one line for each schedule, by due time,
148 // with its ID, its next time, its cron expression or once, and its text on one
149 // line.
150 func (s *Schedules) List() string {
151         location := s.clock().Location()
152         schedules := s.sorted()
153         if len(schedules) == 0 {
154                 return "No schedules."
155         }
156
157         lines := make([]string, 0, len(schedules))
158         for _, schedule := range schedules {
159                 lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " "))
160         }
161
162         return strings.Join(lines, "\n")
163 }
164
165 // Remove removes the schedule id and returns the answer of the tool.
166 func (s *Schedules) Remove(id int) (string, error) {
167         s.mutex.Lock()
168         defer s.mutex.Unlock()
169
170         index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool {
171                 return schedule.ID == id
172         })
173
174         if index < 0 {
175                 return "", fmt.Errorf("no schedule %d", id)
176         }
177
178         s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
179         err := s.save()
180         if err != nil {
181                 return "", err
182         }
183
184         return fmt.Sprintf("Schedule %d removed.", id), nil
185 }
186
187 // Due returns the schedules whose time is not after now, by due time.
188 func (s *Schedules) Due(now time.Time) []Schedule {
189         var due []Schedule
190         for _, schedule := range s.sorted() {
191                 if !schedule.Due.After(now) {
192                         due = append(due, schedule)
193                 }
194         }
195
196         return due
197 }
198
199 // Done ends the run of schedule at now: a one-time schedule goes, and a
200 // repeating schedule gets the next time after now. So a repeating schedule that
201 // missed several times runs once. When a remove took the schedule first, Done
202 // changes nothing.
203 func (s *Schedules) Done(schedule Schedule, now time.Time) error {
204         s.mutex.Lock()
205         defer s.mutex.Unlock()
206
207         index := slices.IndexFunc(s.file.Schedules, func(held Schedule) bool {
208                 return held.ID == schedule.ID
209         })
210
211         if index < 0 {
212                 return nil
213         }
214
215         if schedule.Cron == "" {
216                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
217
218                 return s.save()
219         }
220
221         cron, err := scheduleParseCron(schedule.Cron)
222         if err != nil {
223                 return fmt.Errorf("schedule %d: %w", schedule.ID, err)
224         }
225
226         next, found := cron.Next(now)
227         if !found {
228                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
229
230                 return s.save()
231         }
232
233         s.file.Schedules[index].Due = next
234
235         return s.save()
236 }
237
238 // Wait returns how long after now the first schedule is due, or false when
239 // there is no schedule.
240 func (s *Schedules) Wait(now time.Time) (time.Duration, bool) {
241         schedules := s.sorted()
242         if len(schedules) == 0 {
243                 return 0, false
244         }
245
246         return max(schedules[0].Due.Sub(now), 0), true
247 }
248
249 // sorted returns a copy of the schedules by due time, then by ID.
250 func (s *Schedules) sorted() []Schedule {
251         s.mutex.Lock()
252         schedules := slices.Clone(s.file.Schedules)
253         s.mutex.Unlock()
254
255         slices.SortFunc(schedules, func(a, b Schedule) int {
256                 return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID))
257         })
258
259         return schedules
260 }
261
262 // save writes the file whole, with mode 0600. The caller holds the mutex.
263 func (s *Schedules) save() error {
264         data, err := json.Marshal(s.file)
265         if err == nil {
266                 err = writeFileAtomic(s.path, append(data, '\n'))
267         }
268
269         if err != nil {
270                 return fmt.Errorf("save schedules: %w", err)
271         }
272
273         return nil
274 }
275
276 // ScheduleFile is the content of the file of the schedules: the ID of the last
277 // schedule, so that an ID is never used again, and the schedules.
278 type ScheduleFile struct {
279         Next      int        `json:"next"`
280         Schedules []Schedule `json:"schedules"`
281 }
282
283 // Schedule is one schedule: its ID, the task for the agent, the cron expression
284 // of a repeating schedule, empty for a one-time schedule, and its next time.
285 type Schedule struct {
286         ID   int       `json:"id"`
287         Text string    `json:"text"`
288         Cron string    `json:"cron"`
289         Due  time.Time `json:"due"`
290 }
291
292 // Message returns the text of a run at now for the log, with the times in the
293 // location of now. The first line starts with [Schedule ID] and gives the due
294 // time and once or the cron expression. Then come the text, the line about a
295 // late run when now is more than scheduleLate after the due time, and the line
296 // about the empty reply.
297 func (s Schedule) Message(now time.Time) string {
298         lines := []string{
299                 "[Schedule " + strconv.Itoa(s.ID) + "] " + s.Due.In(now.Location()).Format(scheduleTimeLayout) + ", " + s.repeats(),
300                 s.Text,
301         }
302
303         if now.Sub(s.Due) > scheduleLate {
304                 lines = append(lines, scheduleLateLine)
305         }
306
307         return strings.Join(append(lines, scheduleEmptyReply), "\n")
308 }
309
310 // repeats returns once for a one-time schedule, or else repeats and the cron
311 // expression.
312 func (s Schedule) repeats() string {
313         if s.Cron == "" {
314                 return "once"
315         }
316
317         return "repeats " + s.Cron
318 }