mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Repris d'AJEAN 0.17.5. - task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté serveur : le modèle n'a plus à écrire un cron ni à jongler avec les fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et se désactive après son passage ; manquée pendant un arrêt, elle part au redémarrage. - Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en UTC : « à 17h » tombait deux heures à côté. La réponse de task_create nomme le fuseau utilisé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
8.5 KiB
Go
269 lines
8.5 KiB
Go
package loki
|
|
|
|
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
|
|
//
|
|
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
|
|
// time.ParseDuration, borné à ≥ 1 minute.
|
|
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
|
|
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
|
|
//
|
|
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
|
|
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
|
|
// assez rapide pour une poignée de tâches évaluées une fois par minute.
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
|
|
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
|
|
// les quelques secondes n'a pas de sens.
|
|
const minInterval = time.Minute
|
|
|
|
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
|
|
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
|
|
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
|
|
// se déclencheraient à la mauvaise heure pour l'utilisateur.
|
|
func loc(tz string) *time.Location {
|
|
if tz == "" {
|
|
return time.Local
|
|
}
|
|
l, err := time.LoadLocation(tz)
|
|
if err != nil {
|
|
return time.Local
|
|
}
|
|
return l
|
|
}
|
|
|
|
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
|
|
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
|
|
//
|
|
// Formes acceptées :
|
|
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
|
|
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
|
|
// - expression cron 5 champs.
|
|
// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale.
|
|
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
|
|
s := strings.TrimSpace(schedule)
|
|
if s == "" {
|
|
return time.Time{}, fmt.Errorf("fréquence vide")
|
|
}
|
|
if rest, ok := cutPrefix(s, "@once"); ok {
|
|
at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz))
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)")
|
|
}
|
|
if !at.After(from) {
|
|
return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04"))
|
|
}
|
|
return at, nil
|
|
}
|
|
if rest, ok := cutPrefix(s, "@every"); ok {
|
|
return nextEvery(strings.TrimSpace(rest), tz, from)
|
|
}
|
|
return nextCron(s, tz, from)
|
|
}
|
|
|
|
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
|
|
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
|
|
durPart, timePart, hasTime := strings.Cut(spec, "@")
|
|
durPart = strings.TrimSpace(durPart)
|
|
|
|
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
|
|
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
|
|
if n, ok := cutSuffix(durPart, "d"); ok {
|
|
days, err := strconv.Atoi(n)
|
|
if err != nil || days < 1 {
|
|
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
|
|
}
|
|
if !hasTime {
|
|
return from.Add(time.Duration(days) * 24 * time.Hour), nil
|
|
}
|
|
hh, mm, err := parseHHMM(timePart)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
l := loc(tz)
|
|
now := from.In(l)
|
|
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
|
|
for !cand.After(now) {
|
|
cand = cand.AddDate(0, 0, days)
|
|
}
|
|
return cand, nil
|
|
}
|
|
|
|
d, err := time.ParseDuration(durPart)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
|
|
}
|
|
if d < minInterval {
|
|
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
|
|
}
|
|
return from.Add(d), nil
|
|
}
|
|
|
|
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
|
|
func parseHHMM(s string) (int, int, error) {
|
|
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
|
|
if !ok {
|
|
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
|
|
}
|
|
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
|
|
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
|
|
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
|
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
|
|
}
|
|
return h, m, nil
|
|
}
|
|
|
|
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
|
|
func cutSuffix(s, suffix string) (string, bool) {
|
|
if strings.HasSuffix(s, suffix) {
|
|
return s[:len(s)-len(suffix)], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
|
|
func validateSchedule(schedule, tz string) error {
|
|
_, err := nextAfter(schedule, tz, time.Now())
|
|
return err
|
|
}
|
|
|
|
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
|
|
type cronSpec struct {
|
|
min, hour, dom, month, dow map[int]bool
|
|
}
|
|
|
|
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
|
|
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
|
|
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
|
|
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
|
|
spec, err := parseCron(expr)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
|
|
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
|
|
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
|
|
limit := t.Add(366 * 24 * time.Hour)
|
|
for ; t.Before(limit); t = t.Add(time.Minute) {
|
|
if spec.matches(t) {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
|
|
}
|
|
|
|
func (s cronSpec) matches(t time.Time) bool {
|
|
return s.min[t.Minute()] &&
|
|
s.hour[t.Hour()] &&
|
|
s.dom[t.Day()] &&
|
|
s.month[int(t.Month())] &&
|
|
s.dow[int(t.Weekday())]
|
|
}
|
|
|
|
func parseCron(expr string) (cronSpec, error) {
|
|
f := strings.Fields(expr)
|
|
if len(f) != 5 {
|
|
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
|
|
}
|
|
var s cronSpec
|
|
var err error
|
|
if s.min, err = parseField(f[0], 0, 59); err != nil {
|
|
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
|
|
}
|
|
if s.hour, err = parseField(f[1], 0, 23); err != nil {
|
|
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
|
|
}
|
|
if s.dom, err = parseField(f[2], 1, 31); err != nil {
|
|
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
|
|
}
|
|
if s.month, err = parseField(f[3], 1, 12); err != nil {
|
|
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
|
|
}
|
|
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
|
|
if s.dow, err = parseField(f[4], 0, 7); err != nil {
|
|
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
|
|
}
|
|
if s.dow[7] {
|
|
s.dow[0] = true
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
|
|
// valeurs autorisées dans [lo, hi].
|
|
func parseField(field string, lo, hi int) (map[int]bool, error) {
|
|
out := map[int]bool{}
|
|
for _, part := range strings.Split(field, ",") {
|
|
rng := part
|
|
step := 1
|
|
if base, st, ok := strings.Cut(part, "/"); ok {
|
|
n, err := strconv.Atoi(st)
|
|
if err != nil || n < 1 {
|
|
return nil, fmt.Errorf("pas invalide : %q", part)
|
|
}
|
|
step = n
|
|
rng = base
|
|
}
|
|
start, end := lo, hi
|
|
if rng != "*" {
|
|
if a, b, ok := strings.Cut(rng, "-"); ok {
|
|
var err error
|
|
if start, err = atoiRange(a, lo, hi); err != nil {
|
|
return nil, err
|
|
}
|
|
if end, err = atoiRange(b, lo, hi); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
v, err := atoiRange(rng, lo, hi)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
start, end = v, v
|
|
}
|
|
}
|
|
if start > end {
|
|
return nil, fmt.Errorf("plage inversée : %q", part)
|
|
}
|
|
for v := start; v <= end; v += step {
|
|
out[v] = true
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("champ vide")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func atoiRange(s string, lo, hi int) (int, error) {
|
|
v, err := strconv.Atoi(strings.TrimSpace(s))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("valeur invalide : %q", s)
|
|
}
|
|
if v < lo || v > hi {
|
|
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
|
|
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
|
|
func cutPrefix(s, prefix string) (string, bool) {
|
|
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
|
return s[len(prefix):], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage.
|
|
func isOnce(schedule string) bool {
|
|
_, ok := cutPrefix(strings.TrimSpace(schedule), "@once")
|
|
return ok
|
|
}
|