]> git.giacomolli.uk - onechat/blob - schedule.go
Let the agent keep user instructions
[onechat] / schedule.go
1 package main
2
3 import (
4         "cmp"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "log"
10         "os"
11         "slices"
12         "strconv"
13         "strings"
14         "sync"
15         "time"
16 )
17
18 const (
19         // scheduleFileName is the file of the schedules in the directory of the
20         // agent.
21         scheduleFileName = "schedules.json"
22
23         // scheduleTextBytes is the most bytes of the text of a schedule.
24         scheduleTextBytes = 4096
25
26         // scheduleAtLayout is the layout of the time of a one-time schedule.
27         scheduleAtLayout = "2006-01-02 15:04"
28
29         // scheduleTimeLayout is the layout of the times in the answers of the
30         // tools and in the log.
31         scheduleTimeLayout = "Monday 2006-01-02 15:04 -07:00"
32
33         // scheduleSource is the source of the work events of the runs.
34         scheduleSource = "schedule"
35
36         // scheduleCheck is the longest wait of Run before it looks at the times
37         // again, so that it sees a suspend of the machine or a change of the
38         // clock.
39         scheduleCheck = time.Minute
40
41         // scheduleLate is how long after its time a run is late.
42         scheduleLate = time.Minute
43
44         // scheduleEmptyReply is the last line of the message of a run.
45         scheduleEmptyReply = "If nothing in this task needs the user, end your turn with an empty reply."
46
47         // scheduleLateLine tells the agent that a run is late.
48         scheduleLateLine = "This run is late: the daemon was stopped at that time."
49 )
50
51 // openSchedules reads the schedules of the file at path. A missing file holds
52 // none. The clock gives the time and the time zone of the schedules, and logger
53 // gets one line for each run.
54 func openSchedules(path string, clock func() time.Time, logger *log.Logger) (*Schedules, error) {
55         s := &Schedules{
56                 path:    path,
57                 clock:   clock,
58                 logger:  logger,
59                 check:   scheduleCheck,
60                 changed: make(chan struct{}, 1),
61         }
62
63         data, err := os.ReadFile(path)
64         if errors.Is(err, os.ErrNotExist) {
65                 return s, nil
66         }
67
68         if err != nil {
69                 return nil, fmt.Errorf("read schedules: %w", err)
70         }
71
72         err = json.Unmarshal(data, &s.file)
73         if err != nil {
74                 return nil, fmt.Errorf("%s: %w", path, err)
75         }
76
77         for _, schedule := range s.file.Schedules {
78                 if schedule.Cron == "" {
79                         continue
80                 }
81
82                 _, err = scheduleParseCron(schedule.Cron)
83                 if err != nil {
84                         return nil, fmt.Errorf("%s: schedule %d: %w", path, schedule.ID, err)
85                 }
86         }
87
88         return s, nil
89 }
90
91 // Schedules holds the schedules of the agent: tasks that start a turn at one
92 // time or at the times of a cron expression. Each change writes the file whole
93 // before it returns, so a schedule survives a restart. The mutex protects the
94 // schedules, because the tools change them from a turn while Run reads them.
95 // changed tells Run that a set or a remove changed the times.
96 type Schedules struct {
97         path    string
98         clock   func() time.Time
99         logger  *log.Logger
100         check   time.Duration
101         changed chan struct{}
102
103         mutex sync.Mutex
104         file  ScheduleFile
105 }
106
107 // Run starts the runs of the due schedules in l until ctx ends. It waits until
108 // the first due time, at most check at a time, and also wakes at a set or a
109 // remove. So a schedule that came due while the daemon was stopped runs at the
110 // start. It returns the error of a write of the log or of the file.
111 func (s *Schedules) Run(ctx context.Context, l *TurnLoop) error {
112         for {
113                 err := s.deliver(ctx, l)
114                 if err != nil {
115                         return err
116                 }
117
118                 var timer *time.Timer
119                 var timeout <-chan time.Time
120                 wait, found := s.Wait(s.clock())
121                 if found {
122                         timer = time.NewTimer(min(wait, s.check))
123                         timeout = timer.C
124                 }
125
126                 select {
127                 case <-ctx.Done():
128                         return nil
129                 case <-s.changed:
130                 case <-timeout:
131                 }
132
133                 if timer != nil {
134                         timer.Stop()
135                 }
136         }
137 }
138
139 // Set adds a schedule with text and returns the answer of the tool. Exactly one
140 // of at and cron is not nil. at is a local time in scheduleAtLayout after now,
141 // and it goes through scheduleWallTime, so a time in the hour that the clock
142 // repeats is its first occurrence. cron is an expression of scheduleParseCron.
143 func (s *Schedules) Set(text string, at, cron *string) (string, error) {
144         text = strings.TrimSpace(text)
145         if text == "" || len(text) > scheduleTextBytes {
146                 return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
147         }
148
149         now := s.clock()
150         schedule := Schedule{
151                 Text: text,
152         }
153
154         switch {
155         case (at == nil) == (cron == nil):
156                 return "", errors.New("give either at or cron, and null for the other")
157         case at != nil:
158                 parsed, err := time.Parse(scheduleAtLayout, *at)
159                 if err != nil {
160                         return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
161                 }
162
163                 schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
164                 if !schedule.Due.After(now) {
165                         return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
166                 }
167         default:
168                 parsed, err := scheduleParseCron(*cron)
169                 if err != nil {
170                         return "", err
171                 }
172
173                 next, found := parsed.Next(now)
174                 if !found {
175                         return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
176                 }
177
178                 schedule.Cron = parsed.String()
179                 schedule.Due = next
180         }
181
182         s.mutex.Lock()
183         defer s.mutex.Unlock()
184
185         s.file.Next++
186         schedule.ID = s.file.Next
187         s.file.Schedules = append(s.file.Schedules, schedule)
188         err := s.save()
189         if err != nil {
190                 return "", err
191         }
192
193         s.change()
194
195         return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil
196 }
197
198 // List returns the answer of the tool: one line for each schedule, by due time,
199 // with its ID, its next time, its cron expression or once, and its text on one
200 // line.
201 func (s *Schedules) List() string {
202         location := s.clock().Location()
203         schedules := s.sorted()
204         if len(schedules) == 0 {
205                 return "No schedules."
206         }
207
208         lines := make([]string, 0, len(schedules))
209         for _, schedule := range schedules {
210                 lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " "))
211         }
212
213         return strings.Join(lines, "\n")
214 }
215
216 // Len returns the number of schedules.
217 func (s *Schedules) Len() int {
218         s.mutex.Lock()
219         defer s.mutex.Unlock()
220
221         return len(s.file.Schedules)
222 }
223
224 // Remove removes the schedule id and returns the answer of the tool.
225 func (s *Schedules) Remove(id int) (string, error) {
226         s.mutex.Lock()
227         defer s.mutex.Unlock()
228
229         index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool {
230                 return schedule.ID == id
231         })
232
233         if index < 0 {
234                 return "", fmt.Errorf("no schedule %d", id)
235         }
236
237         s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
238         err := s.save()
239         if err != nil {
240                 return "", err
241         }
242
243         s.change()
244
245         return fmt.Sprintf("Schedule %d removed.", id), nil
246 }
247
248 // deliver logs the message of each due schedule in l, in due order, as a work
249 // message of scheduleSource, and wakes the loop once with all of them. A
250 // schedule ends its run with Done after its message is durable in the log. A
251 // crash between the two makes the run repeat once. Each run logs one line with
252 // the ID of the schedule and, for a late run, how late it is. The line holds no
253 // text of the schedule.
254 func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
255         now := s.clock()
256         var turns []Message
257         for _, schedule := range s.Due(now) {
258                 message, err := l.Work(scheduleSource, schedule.Message(now), "")
259                 if err != nil {
260                         return fmt.Errorf("log a schedule: %w", err)
261                 }
262
263                 // A stop of the daemon during the flush is no failure: the
264                 // schedule stays due and runs at the next start.
265                 err = l.Flush(ctx)
266                 if ctx.Err() != nil {
267                         return nil
268                 }
269
270                 if err != nil {
271                         return fmt.Errorf("log a schedule: %w", err)
272                 }
273
274                 err = s.Done(schedule, now)
275                 if err != nil {
276                         return err
277                 }
278
279                 if schedule.late(now) {
280                         s.logger.Printf("schedule %d ran %s late", schedule.ID, durationText(now.Sub(schedule.Due)))
281                 } else {
282                         s.logger.Printf("schedule %d ran", schedule.ID)
283                 }
284
285                 turns = append(turns, message)
286         }
287
288         l.Wake(turns...)
289
290         return nil
291 }
292
293 // Due returns the schedules whose time is not after now, by due time.
294 func (s *Schedules) Due(now time.Time) []Schedule {
295         var due []Schedule
296         for _, schedule := range s.sorted() {
297                 if !schedule.Due.After(now) {
298                         due = append(due, schedule)
299                 }
300         }
301
302         return due
303 }
304
305 // Done ends the run of schedule at now: a one-time schedule goes, and a
306 // repeating schedule gets the next time after now. So a repeating schedule that
307 // missed several times runs once. When a remove took the schedule first, Done
308 // changes nothing.
309 func (s *Schedules) Done(schedule Schedule, now time.Time) error {
310         s.mutex.Lock()
311         defer s.mutex.Unlock()
312
313         index := slices.IndexFunc(s.file.Schedules, func(held Schedule) bool {
314                 return held.ID == schedule.ID
315         })
316
317         if index < 0 {
318                 return nil
319         }
320
321         if schedule.Cron == "" {
322                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
323
324                 return s.save()
325         }
326
327         cron, err := scheduleParseCron(schedule.Cron)
328         if err != nil {
329                 return fmt.Errorf("schedule %d: %w", schedule.ID, err)
330         }
331
332         next, found := cron.Next(now)
333         if !found {
334                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
335
336                 return s.save()
337         }
338
339         s.file.Schedules[index].Due = next
340
341         return s.save()
342 }
343
344 // Wait returns how long after now the first schedule is due, or false when
345 // there is no schedule.
346 func (s *Schedules) Wait(now time.Time) (time.Duration, bool) {
347         schedules := s.sorted()
348         if len(schedules) == 0 {
349                 return 0, false
350         }
351
352         return max(schedules[0].Due.Sub(now), 0), true
353 }
354
355 // change wakes Run after a set or a remove.
356 func (s *Schedules) change() {
357         select {
358         case s.changed <- struct{}{}:
359         default:
360         }
361 }
362
363 // sorted returns a copy of the schedules by due time, then by ID.
364 func (s *Schedules) sorted() []Schedule {
365         s.mutex.Lock()
366         schedules := slices.Clone(s.file.Schedules)
367         s.mutex.Unlock()
368
369         slices.SortFunc(schedules, func(a, b Schedule) int {
370                 return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID))
371         })
372
373         return schedules
374 }
375
376 // save writes the file whole, with mode 0600. The caller holds the mutex.
377 func (s *Schedules) save() error {
378         data, err := json.Marshal(s.file)
379         if err == nil {
380                 err = writeFileAtomic(s.path, append(data, '\n'))
381         }
382
383         if err != nil {
384                 return fmt.Errorf("save schedules: %w", err)
385         }
386
387         return nil
388 }
389
390 // ScheduleFile is the content of the file of the schedules: the ID of the last
391 // schedule, so that an ID is never used again, and the schedules.
392 type ScheduleFile struct {
393         Next      int        `json:"next"`
394         Schedules []Schedule `json:"schedules"`
395 }
396
397 // Schedule is one schedule: its ID, the task for the agent, the cron expression
398 // of a repeating schedule, empty for a one-time schedule, and its next time.
399 type Schedule struct {
400         ID   int       `json:"id"`
401         Text string    `json:"text"`
402         Cron string    `json:"cron"`
403         Due  time.Time `json:"due"`
404 }
405
406 // Message returns the text of a run at now for the log, with the times in the
407 // location of now. The first line starts with [Schedule ID] and gives the due
408 // time and once or the cron expression. Then come the text, the line about a
409 // late run when now is more than scheduleLate after the due time, and the line
410 // about the empty reply.
411 func (s Schedule) Message(now time.Time) string {
412         lines := []string{
413                 "[Schedule " + strconv.Itoa(s.ID) + "] " + s.Due.In(now.Location()).Format(scheduleTimeLayout) + ", " + s.repeats(),
414                 s.Text,
415         }
416
417         if s.late(now) {
418                 lines = append(lines, scheduleLateLine)
419         }
420
421         return strings.Join(append(lines, scheduleEmptyReply), "\n")
422 }
423
424 // late tells if a run at now is late: more than scheduleLate after the due
425 // time.
426 func (s Schedule) late(now time.Time) bool {
427         return now.Sub(s.Due) > scheduleLate
428 }
429
430 // repeats returns once for a one-time schedule, or else repeats and the cron
431 // expression.
432 func (s Schedule) repeats() string {
433         if s.Cron == "" {
434                 return "once"
435         }
436
437         return "repeats " + s.Cron
438 }