diff --git a/README.md b/README.md index c0daa71..b0c207d 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,17 @@ Héritées d'AJEAN : `npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les lancements suivants partent du cache en quelques secondes. +- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une + fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une + expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit + pas dans le fil, travaille dans son propre dossier (`workspace/tasks//`) et + livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le + compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au + passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à + activer avant l'exécution, accès mémoire et web. Un **interrupteur maître** + suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun + outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une + tâche attend son tour, et le bouton stop du chat l'interrompt. - **Presets** de configuration par modèle, bench, auto-détection GPU. - **Échantillonnage réglable par preset** : température, `top_p`, `top_k`, `min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 9591bd9..5f0b403 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -56,6 +56,12 @@ type Conversation struct { Generating bool `json:"-"` cancel context.CancelFunc // annule la génération en cours (/stop) epoch int // incrémenté à chaque reset → invalide les abonnés + // Tâche planifiée en cours (tasks_run.go). Le verrou de génération est le + // SEUL point partagé entre une tâche de fond et le chat : ces deux champs + // disent à l'interface POURQUOI elle est occupée, au lieu de laisser croire + // à une génération fantôme dans la discussion ouverte. + runningTaskID string + runningTaskName string } var conv = func() *Conversation { @@ -286,7 +292,14 @@ func (c *Conversation) state() map[string]any { turns++ } } - return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} + st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} + // Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que + // d'afficher un tour qui n'apparaîtra jamais dans le fil. + if c.runningTaskID != "" { + st["task_id"] = c.runningTaskID + st["task_name"] = c.runningTaskName + } + return st } // isGenerating dit si un tour est en cours. Sert à la LISTE des discussions : @@ -301,6 +314,20 @@ func (c *Conversation) isGenerating() bool { // ErrBusy : une génération est déjà en cours (un seul tour à la fois). var ErrBusy = fmt.Errorf("génération en cours") +// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé +// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors +// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On +// nomme la tâche et on dit comment reprendre la main. +func (c *Conversation) busyReason() error { + c.mu.Lock() + name := c.runningTaskName + c.mu.Unlock() + if name == "" { + return ErrBusy + } + return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name) +} + // errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce // n'est pas un défaut mais une attente, et le message doit le dire. var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index c6921d7..46581ab 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -161,7 +161,7 @@ func machineSystemPrompt(caps Caps) string { if u, err := user.Current(); err == nil { who = u.Username } - cwd := convWorkspace() + cwd := agentCwd() var b strings.Builder b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH)) @@ -211,7 +211,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string { // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de // tout refuser. - if ws := convWorkspace(); ws != "" { + if ws := agentCwd(); ws != "" { if err := os.MkdirAll(ws, 0o755); err == nil { cmd.Dir = ws } diff --git a/internal/loki/chat_workspace.go b/internal/loki/chat_workspace.go index fbf75c8..cfad8dd 100644 --- a/internal/loki/chat_workspace.go +++ b/internal/loki/chat_workspace.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" ) // Dossier de travail du mode agent. @@ -21,7 +22,8 @@ import ( // agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif // est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour // qu'un fichier appartienne à la discussion où il est né et disparaisse avec -// elle. La racine reste la borne de sécurité des téléchargements. +// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine +// reste la borne de sécurité des téléchargements. const workspaceEnv = "LOKI_WORKSPACE" @@ -73,6 +75,30 @@ func workspaceCandidates() []string { return c } +// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée +// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses +// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte +// — et changeraient de place en cours de route si l'utilisateur en changeait. +// +// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas +// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois +// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent +// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des +// messages — continue de passer par convWorkspace() et reste donc sur sa +// discussion, même pendant qu'une tâche écrit ailleurs. +var taskWS atomic.Value // string + +func setTaskWorkspace(dir string) { taskWS.Store(dir) } + +// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en +// cours s'il y en a une, sinon celui de la discussion ouverte. +func agentCwd() string { + if d, _ := taskWS.Load().(string); d != "" { + return d + } + return convWorkspace() +} + // resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ; // "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la // discussion ouverte. @@ -89,5 +115,5 @@ func resolveAgentPath(p string) string { if filepath.IsAbs(p) { return p } - return filepath.Join(convWorkspace(), filepath.FromSlash(p)) + return filepath.Join(agentCwd(), filepath.FromSlash(p)) } diff --git a/internal/loki/code_git.go b/internal/loki/code_git.go index a6f76d0..40a4dc1 100644 --- a/internal/loki/code_git.go +++ b/internal/loki/code_git.go @@ -74,7 +74,7 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string { cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(cctx, "git", args...) - cmd.Dir = convWorkspace() + cmd.Dir = agentCwd() // Jamais d'invite interactive (mot de passe, hôte inconnu) : un process // serveur n'a personne pour y répondre, il faut échouer vite et le dire. cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes") @@ -137,7 +137,7 @@ func toolGitClone(ctx context.Context, args map[string]any) string { if strings.Contains(dir, "..") || filepath.IsAbs(dir) { return "[erreur] dossier cible invalide" } - target := filepath.Join(convWorkspace(), dir) + target := filepath.Join(agentCwd(), dir) if _, err := os.Stat(target); err == nil { return "[erreur] le dossier " + dir + " existe déjà" } diff --git a/internal/loki/code_jobs.go b/internal/loki/code_jobs.go index 58d871a..b568993 100644 --- a/internal/loki/code_jobs.go +++ b/internal/loki/code_jobs.go @@ -83,7 +83,7 @@ func toolBashBg(args map[string]any) string { if reason := dangerousCommand(command); reason != "" { return refusedCommandResult(reason) } - ws := convWorkspace() + ws := agentCwd() logDir := filepath.Join(ws, ".loki", "jobs") if err := os.MkdirAll(logDir, 0o755); err != nil { return "[erreur] " + err.Error() diff --git a/internal/loki/code_mode.go b/internal/loki/code_mode.go index 41aa594..8b4d285 100644 --- a/internal/loki/code_mode.go +++ b/internal/loki/code_mode.go @@ -64,7 +64,7 @@ func looksLikeCode(text string) bool { // convHasRepo : un dépôt git vit déjà dans le dossier de la discussion. func convHasRepo() bool { - ws := convWorkspace() + ws := agentCwd() if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil { return true } diff --git a/internal/loki/code_policy.go b/internal/loki/code_policy.go index d345a43..f4ad122 100644 --- a/internal/loki/code_policy.go +++ b/internal/loki/code_policy.go @@ -74,7 +74,7 @@ func refusedCommandResult(reason string) string { // le dossier de la discussion courante. Utilisé par les outils fichiers en mode // code uniquement. func codePathAllowed(abs string) bool { - ws := convWorkspace() + ws := agentCwd() if ws == "" { return false } diff --git a/internal/loki/code_tools.go b/internal/loki/code_tools.go index 48b20c9..38a5415 100644 --- a/internal/loki/code_tools.go +++ b/internal/loki/code_tools.go @@ -178,7 +178,7 @@ func toolGrep(args map[string]any, codeMode bool) string { if err != nil { return "[erreur] motif invalide : " + err.Error() } - root := convWorkspace() + root := agentCwd() if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." { root = resolveAgentPath(p) } @@ -246,7 +246,7 @@ func toolGlob(args map[string]any, codeMode bool) string { if strings.TrimSpace(pattern) == "" { return "[erreur] motif vide" } - root := convWorkspace() + root := agentCwd() re, err := globToRegexp(pattern) if err != nil { return "[erreur] motif invalide : " + err.Error() diff --git a/internal/loki/lsp.go b/internal/loki/lsp.go index f093b5a..067b50f 100644 --- a/internal/loki/lsp.go +++ b/internal/loki/lsp.go @@ -128,7 +128,7 @@ func lspDiagnosticsFor(absPath string) string { if _, err := exec.LookPath(lang.Command[0]); err != nil { return "" // serveur non installé : le mode code marche sans } - root := convWorkspace() + root := agentCwd() // Le dépôt cloné est la vraie racine projet quand le fichier est dedans : // gopls a besoin du go.mod, tsserver du tsconfig. if r := projectRootFor(absPath, root); r != "" { diff --git a/internal/loki/store.go b/internal/loki/store.go index 4c6a91a..1c44200 100644 --- a/internal/loki/store.go +++ b/internal/loki/store.go @@ -33,6 +33,7 @@ const ( bkPrefs = "prefs" // préférences de l'UI web bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP bkChat = "chat" // conversation partagée + bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche) ) // La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré. diff --git a/internal/loki/tasks.go b/internal/loki/tasks.go new file mode 100644 index 0000000..ee3b249 --- /dev/null +++ b/internal/loki/tasks.go @@ -0,0 +1,102 @@ +package loki + +// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule, +// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque +// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans +// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres +// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une +// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused). + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "sort" + "time" + + bolt "go.etcd.io/bbolt" +) + +// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID). +type Task struct { + ID string `json:"id"` + Name string `json:"name"` + Prompt string `json:"prompt"` + Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs + TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure + Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif) + // Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON + // (tâches créées avant ces champs) garde le comportement historique : accès. + NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche + NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche + Enabled bool `json:"enabled"` + LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais + NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer + LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms + LastOK bool `json:"last_ok"` + LastError string `json:"last_error"` + LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court) + + Running bool `json:"-"` // transitoire : une exécution est en cours +} + +// newTaskID génère un identifiant court et unique. +func newTaskID() string { + var b [6]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable. +func listTasks() []Task { + var out []Task + _ = view(bkTasks, func(b *bolt.Bucket) error { + return b.ForEach(func(_, v []byte) error { + var t Task + if json.Unmarshal(v, &t) == nil { + out = append(out, t) + } + return nil + }) + }) + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].ID < out[j].ID + }) + return out +} + +// getTask lit une tâche par ID. Renvoie false si absente. +func getTask(id string) (Task, bool) { + var t Task + ok := getJSON(bkTasks, id, &t) + return t, ok +} + +// saveTask enregistre (crée ou remplace) une tâche. +func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) } + +// deleteTask supprime une tâche par ID. +func deleteTask(id string) error { return putBytes(bkTasks, id, nil) } + +// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On +// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base +// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un +// moteur. +func tasksPaused() bool { return getBool(bkState, "tasks_paused") } + +func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) } + +// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son +// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est +// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un +// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou). +func computeNextRun(schedule, tz string, from time.Time) int64 { + next, err := nextAfter(schedule, tz, from) + if err != nil { + return 0 + } + return next.UnixMilli() +} diff --git a/internal/loki/tasks_cron.go b/internal/loki/tasks_cron.go new file mode 100644 index 0000000..d1e88f2 --- /dev/null +++ b/internal/loki/tasks_cron.go @@ -0,0 +1,251 @@ +package loki + +// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes : +// +// - Intervalle : "@every " (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. +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, "@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 +} diff --git a/internal/loki/tasks_cron_test.go b/internal/loki/tasks_cron_test.go new file mode 100644 index 0000000..654a55a --- /dev/null +++ b/internal/loki/tasks_cron_test.go @@ -0,0 +1,142 @@ +package loki + +import ( + "testing" + "time" +) + +func TestNextAfterInterval(t *testing.T) { + from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC) + got, err := nextAfter("@every 2h", "", from) + if err != nil { + t.Fatalf("erreur inattendue : %v", err) + } + want := from.Add(2 * time.Hour) + if !got.Equal(want) { + t.Fatalf("@every 2h : got %v, want %v", got, want) + } + // Casse tolérée sur le préfixe. + if _, err := nextAfter("@EVERY 30m", "", from); err != nil { + t.Fatalf("@EVERY 30m devrait être valide : %v", err) + } + // Sous la minute → refusé. + if _, err := nextAfter("@every 10s", "", from); err == nil { + t.Fatalf("@every 10s aurait dû être refusé") + } + if _, err := nextAfter("@every pouet", "", from); err == nil { + t.Fatalf("durée invalide aurait dû être refusée") + } +} + +func TestNextEveryDays(t *testing.T) { + from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC) + // Sans ancre horaire : intervalle glissant de N*24h. + got, err := nextAfter("@every 3d", "", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + if want := from.Add(72 * time.Hour); !got.Equal(want) { + t.Fatalf("@every 3d : got %v, want %v", got, want) + } +} + +func TestNextEveryDayAtTime(t *testing.T) { + // « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août). + from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris + got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + // 23:00 Paris le 15 = 21:00 UTC. + want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want) + } + // Après 23:00 Paris → le lendemain. + from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris + got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2) + want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC) + if !got2.Equal(want2) { + t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2) + } +} + +func TestNextCronEveryQuarter(t *testing.T) { + from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC) + got, err := nextAfter("*/15 * * * *", "", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("*/15 : got %v, want %v", got, want) + } +} + +func TestNextCronWeekdayMorning(t *testing.T) { + // 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00. + from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) + got, err := nextAfter("0 9 * * 1-5", "UTC", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want) + } +} + +func TestNextCronInParisTZ(t *testing.T) { + // « 0 9 * * * » à Paris = 07:00 UTC en été. + from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) + got, err := nextAfter("0 9 * * *", "Europe/Paris", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want) + } +} + +func TestNextCronSundayBothForms(t *testing.T) { + from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi + got, err := nextAfter("0 0 * * 7", "UTC", from) + if err != nil { + t.Fatalf("erreur : %v", err) + } + want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("dow=7 : got %v, want %v", got, want) + } +} + +func TestParseCronInvalid(t *testing.T) { + cases := []string{ + "", + "* * * *", // 4 champs + "* * * * * *", // 6 champs + "60 * * * *", // minute hors bornes + "* 24 * * *", // heure hors bornes + "5-1 * * * *", // plage inversée + "*/0 * * * *", // pas nul + "abc * * * *", // non numérique + "@every", // durée absente + "@every 1d@25:00", // heure hors bornes + "@every 0d@10:00", // zéro jour + } + for _, c := range cases { + if err := validateSchedule(c, ""); err == nil { + t.Errorf("schedule %q aurait dû être refusé", c) + } + } +} + +func TestValidateScheduleOK(t *testing.T) { + cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"} + for _, c := range cases { + if err := validateSchedule(c, "Europe/Paris"); err != nil { + t.Errorf("schedule %q aurait dû être valide : %v", c, err) + } + } +} diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go new file mode 100644 index 0000000..5d23413 --- /dev/null +++ b/internal/loki/tasks_run.go @@ -0,0 +1,269 @@ +package loki + +// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte. +// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki +// (dossier de travail par discussion, modes de la machine). +// +// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer +// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec +// la conversation : son verrou de génération (conv.Generating), qui garantit +// qu'une seule inférence tourne à la fois — llama-server est lancé en +// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais +// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne +// gardant que le texte final comme compte-rendu. + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la +// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu). +// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou +// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas +// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet). +// lastReport est le compte-rendu du passage précédent (vide au premier) : il est +// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre. +func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) { + if !healthCheck() { + return "", errModelLoading + } + // Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop + // du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de + // fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + c.mu.Lock() + if c.Generating { + c.mu.Unlock() + return "", ErrBusy + } + c.Generating = true + c.cancel = cancel + c.runningTaskID = taskID + c.runningTaskName = taskName + c.mu.Unlock() + // Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI + // c.epoch : la discussion ouverte reste totalement à l'écart. + defer func() { + c.mu.Lock() + c.Generating = false + c.cancel = nil + c.runningTaskID = "" + c.runningTaskName = "" + c.mu.Unlock() + }() + + // Dossier de travail de la tâche : le sien, pas celui de la discussion + // ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui + // tient un fichier de suivi le retrouve la fois suivante. + setTaskWorkspace(taskWorkspaceDir(taskID)) + defer setTaskWorkspace("") + + if temperature == 0 { + temperature = 0.7 + } + msgs := []Message{{Role: "user", Content: prompt}} + // Même préambule que la vraie génération : consigne personnelle + préambule + // agent + briefing machine (via InjectSkills), pour que l'IA ait le même + // contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de + // contexte (conscience du mode autonome + mémoire du passage précédent), + // fusionnée dans UN SEUL message système en tête — comme l'exigent les + // gabarits stricts (normalizeSystemMessages le garantit de toute façon). + final := msgs + sys := readSysPrompt() + note := taskContextNote(taskName, lastReport) + switch { + case sys != "" && note != "": + final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...) + case sys != "": + final = append([]Message{{Role: "system", Content: sys}}, msgs...) + case note != "": + final = append([]Message{{Role: "system", Content: note}}, msgs...) + } + + var content strings.Builder + _, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { + if ev.Content != "" { + content.WriteString(ev.Content) + } + return true + }) + // Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul + // son compte-rendu survit. + return strings.TrimSpace(content.String()), err +} + +// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la +// racine du dossier de travail — jamais dans le dossier d'une discussion, dont +// il partagerait le sort à la suppression. +func taskWorkspaceDir(taskID string) string { + root := agentWorkspace() + if taskID == "" { + return root + } + dir := filepath.Join(root, "tasks", taskID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return root + } + return dir +} + +// taskContextNote construit la note de contexte injectée en tête du fil d'une +// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en +// face), puis — s'il existe — le compte-rendu du passage précédent, pour la +// continuité. +func taskContextNote(taskName, lastReport string) string { + var b strings.Builder + b.WriteString("[Tâche planifiée") + if taskName != "" { + b.WriteString(" « " + taskName + " »") + } + b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " + + "présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " + + "termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.") + if r := strings.TrimSpace(lastReport); r != "" { + if len(r) > reportMax { + r = r[:reportMax] + "…" + } + b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " + + "appuie-toi dessus, ne le répète pas tel quel) :\n" + r) + } + return b.String() +} + +// reportMax borne la taille du compte-rendu conservé : le texte final peut être +// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante. +const reportMax = 4000 + +// runTask exécute une tâche et met à jour son état persisté (dernier passage, +// prochain passage, succès/échec, compte-rendu). +func runTask(t Task) { + start := time.Now() + + // Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement + // du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai + // échec de la tâche : la faire tourner sur le mauvais modèle serait pire. + if err := ensureTaskPreset(t.Preset); err != nil { + recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err)) + return + } + + report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0) + + // Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste + // un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé) + // pour qu'elle soit réessayée au tick suivant. + if err == ErrBusy || err == errModelLoading { + return + } + recordTaskEnd(t.ID, start, report, err) +} + +// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine +// (qui débloque les outils), puis on applique les réglages propres à la tâche pour +// la mémoire et le web. Le web reste borné à ce que la machine offre réellement +// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer. +// +// Le mode code reste OFF : c'est un mode de discussion (rôles, critères, +// vérification, puces dans le fil) qui n'a pas de sens sans personne en face. +func taskCaps(t Task) Caps { + // Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide, + // que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) — + // une tâche sans agent se serait donc vu offrir les outils mem_*. + c := Caps{Agent: agentEnabled(), Mem: MemOff} + if !c.Agent { + return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens + } + if t.NoWeb { + c.Internet = false + } else { + c.Internet = internetEnabled() && crawlReachable() + } + if t.NoMem { + c.Mem = MemOff + } else if m := memMode(); m == MemOff { + // La tâche veut la mémoire mais la machine l'a coupée globalement : on donne + // au moins les outils à la demande, sans l'injection proactive. + c.Mem = MemOnDemand + } else { + c.Mem = m + } + return c +} + +// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà +// actif, puis attend que le modèle ait rechargé. Vide = rien à faire. +func ensureTaskPreset(id string) error { + if id == "" { + return nil + } + list, err := ListPresets() + if err != nil { + return err + } + var target *Preset + for i := range list { + if list[i].ID == id { + target = &list[i] + break + } + } + if target == nil { + return fmt.Errorf("preset introuvable : %s", id) + } + if target.Active { + return nil + } + if err := SwitchToPreset(target.Path); err != nil { + return err + } + // Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros + // modèle prend un moment). + deadline := time.Now().Add(4 * time.Minute) + for time.Now().Before(deadline) { + if healthCheck() { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset") +} + +// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution : +// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la +// tâche juste avant d'écrire pour ne pas écraser une modification concurrente +// (bascule ou édition depuis l'interface pendant l'exécution). +func recordTaskEnd(id string, start time.Time, report string, err error) { + now := time.Now() + cur, ok := getTask(id) + if !ok { + return // supprimée entre-temps : rien à écrire + } + cur.LastDurMs = now.Sub(start).Milliseconds() + cur.LastRun = now.UnixMilli() + cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now) + if errors.Is(err, context.Canceled) { + // Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace + // « interrompue » sans allumer l'indicateur rouge d'erreur. + cur.LastOK = true + cur.LastError = "" + cur.LastReport = "(interrompue manuellement)" + } else if err != nil { + cur.LastOK = false + cur.LastError = err.Error() + } else { + cur.LastOK = true + cur.LastError = "" + if len(report) > reportMax { + report = report[:reportMax] + "…" + } + cur.LastReport = report + } + _ = saveTask(cur) +} diff --git a/internal/loki/tasks_sched.go b/internal/loki/tasks_sched.go new file mode 100644 index 0000000..2b9ae7f --- /dev/null +++ b/internal/loki/tasks_sched.go @@ -0,0 +1,72 @@ +package loki + +// tasks_sched.go — la boucle de planification. Une seule goroutine, un tick par +// minute, dans le process qui possède la conversation et l'UI (ajean-ui / +// `ajean link`). Voir StartTaskScheduler, câblée dans newWebMux (web_server.go). + +import ( + "sync" + "time" +) + +var taskSchedOnce sync.Once + +// StartTaskScheduler démarre la boucle de planification (idempotent : sûr à +// appeler à chaque construction du mux, ne lance qu'une goroutine). +func StartTaskScheduler() { + taskSchedOnce.Do(func() { go taskSchedLoop() }) +} + +// taskSchedLoop réveille les tâches dues, une par tick (elles sont de toute façon +// sérialisées par le gate de génération). Le tick d'une minute suffit : c'est la +// résolution de la planification (voir tasks_cron.go). +func taskSchedLoop() { + // Premier passage immédiat pour amorcer les NextRun manquants (après un + // redémarrage du process, ou pour une tâche fraîchement créée sans NextRun). + primeNextRuns() + t := time.NewTicker(time.Minute) + defer t.Stop() + for range t.C { + tickTasks(time.Now()) + } +} + +// primeNextRuns renseigne NextRun de toute tâche activée qui n'en a pas (0), +// relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due » +// en permanence, ou jamais planifiée selon le sens de la comparaison. +func primeNextRuns() { + now := time.Now() + for _, t := range listTasks() { + if t.Enabled && t.NextRun == 0 { + t.NextRun = computeNextRun(t.Schedule, t.TZ, now) + _ = saveTask(t) + } + } +} + +// tickTasks lance la première tâche due (si l'interrupteur maître n'est pas en +// pause). Une seule par tick : lancer plusieurs inférences en parallèle est de +// toute façon impossible (gate de génération), et étaler leur départ évite qu'une +// rafale de tâches monopolise le modèle d'un coup. +func tickTasks(now time.Time) { + if tasksPaused() { + return + } + nowMs := now.UnixMilli() + for _, t := range listTasks() { + if !t.Enabled { + continue + } + if t.NextRun == 0 { + // Priming raté (schedule invalide) ou tâche neuve : on tente de le poser, + // sans la déclencher ce tour-ci. + t.NextRun = computeNextRun(t.Schedule, t.TZ, now) + _ = saveTask(t) + continue + } + if t.NextRun <= nowMs { + runTask(t) // met à jour LastRun/NextRun/… et persiste + return + } + } +} diff --git a/internal/loki/tasks_test.go b/internal/loki/tasks_test.go new file mode 100644 index 0000000..194b8e8 --- /dev/null +++ b/internal/loki/tasks_test.go @@ -0,0 +1,322 @@ +package loki + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +// Aller-retour complet en base : ce qui est enregistré se relit à l'identique, +// la liste est triée par nom (affichage stable) et la suppression efface bien. +func TestTasksCRUD(t *testing.T) { + testHome(t) + if got := listTasks(); len(got) != 0 { + t.Fatalf("base neuve : %d tâches, attendu 0", len(got)) + } + a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true} + b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"} + if err := saveTask(a); err != nil { + t.Fatal(err) + } + if err := saveTask(b); err != nil { + t.Fatal(err) + } + list := listTasks() + if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" { + t.Fatalf("liste non triée par nom : %+v", list) + } + got, ok := getTask(a.ID) + if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled { + t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok) + } + if err := deleteTask(a.ID); err != nil { + t.Fatal(err) + } + if _, ok := getTask(a.ID); ok { + t.Error("tâche supprimée toujours présente") + } + if len(listTasks()) != 1 { + t.Error("la suppression n'a pas retiré la tâche de la liste") + } +} + +// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans +// dépendre d'une écriture préalable. +func TestTasksPauseDefautActif(t *testing.T) { + testHome(t) + if tasksPaused() { + t.Error("base neuve : les tâches devraient être actives") + } + if err := setTasksPaused(true); err != nil { + t.Fatal(err) + } + if !tasksPaused() { + t.Error("pause non enregistrée") + } + _ = setTasksPaused(false) + if tasksPaused() { + t.Error("reprise non enregistrée") + } +} + +// Le dossier de travail d'une tâche lui appartient : jamais celui d'une +// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une +// tâche qui tient un fichier de suivi doit le retrouver. +func TestTaskWorkspaceIsole(t *testing.T) { + testHome(t) + dir := taskWorkspaceDir("abc123") + if dir == agentWorkspace() || dir == convWorkspace() { + t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir) + } + if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" { + t.Errorf("emplacement inattendu : %s", dir) + } + if again := taskWorkspaceDir("abc123"); again != dir { + t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again) + } + // La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur + // (panneau Fichiers, dépôts) reste sur sa discussion. + setTaskWorkspace(dir) + defer setTaskWorkspace("") + if agentCwd() != dir { + t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir) + } + if convWorkspace() == dir { + t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route") + } +} + +// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER +// (no_mem/no_web), et le mode code reste hors sujet sans personne en face. +func TestTaskCaps(t *testing.T) { + testHome(t) + if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff { + t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c) + } + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + c := taskCaps(Task{}) + if !c.Agent { + t.Fatal("mode agent actif : la tâche doit avoir les outils") + } + if c.Code { + t.Error("le mode code n'a pas de sens pour une tâche autonome") + } + // Par défaut, la tâche suit le mode mémoire de la machine. + if c.Mem != memMode() { + t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode()) + } + // Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les + // outils à la demande, sans l'injection proactive. + if err := setMemMode(MemOff); err != nil { + t.Fatal(err) + } + if got := taskCaps(Task{}).Mem; got != MemOnDemand { + t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand) + } + if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet { + t.Errorf("refus explicite non respecté : %+v", c) + } + // Web : borné à ce que la machine offre réellement — une tâche ne peut pas + // l'inventer (aucun serveur Crawl4AI joignable ici). + if c := taskCaps(Task{}); c.Internet { + t.Error("accès web accordé alors que la machine ne l'offre pas") + } +} + +// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la +// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge). +func TestRecordTaskEnd(t *testing.T) { + testHome(t) + base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true} + if err := saveTask(base); err != nil { + t.Fatal(err) + } + start := time.Now().Add(-2 * time.Second) + + recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil) + got, _ := getTask("t1") + if !got.LastOK || got.LastError != "" { + t.Errorf("succès mal enregistré : %+v", got) + } + if len(got.LastReport) != reportMax+len("…") { + t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport)) + } + if got.LastRun == 0 || got.NextRun <= got.LastRun { + t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun) + } + if got.LastDurMs < 1000 { + t.Errorf("durée non mesurée : %d ms", got.LastDurMs) + } + + recordTaskEnd("t1", start, "", errors.New("boum")) + got, _ = getTask("t1") + if got.LastOK || got.LastError != "boum" { + t.Errorf("échec mal enregistré : %+v", got) + } + + recordTaskEnd("t1", start, "", context.Canceled) + got, _ = getTask("t1") + if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") { + t.Errorf("interruption prise pour un échec : %+v", got) + } + + // Tâche supprimée entre-temps : on n'en ressuscite pas une. + recordTaskEnd("disparue", start, "r", nil) + if _, ok := getTask("disparue"); ok { + t.Error("recordTaskEnd a recréé une tâche supprimée") + } +} + +// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le +// NextRun manquant sans lancer la tâche pour autant. +func TestTickTasks(t *testing.T) { + testHome(t) + now := time.Now() + due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true, + NextRun: now.Add(-time.Minute).UnixMilli()} + if err := saveTask(due); err != nil { + t.Fatal(err) + } + _ = setTasksPaused(true) + tickTasks(now) + if got, _ := getTask("due"); got.LastRun != 0 { + t.Error("une tâche s'est exécutée alors que tout est suspendu") + } + _ = setTasksPaused(false) + + // NextRun absent : le tic le pose (relatif à maintenant) sans exécuter. + neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true} + if err := saveTask(neuve); err != nil { + t.Fatal(err) + } + primeNextRuns() + got, _ := getTask("neuve") + if got.NextRun == 0 || got.LastRun != 0 { + t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun) + } + if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute { + t.Errorf("prochain passage à %v, attendu ~2 h", d) + } + // Tâche désactivée : jamais amorcée, jamais lancée. + off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"} + if err := saveTask(off); err != nil { + t.Fatal(err) + } + primeNextRuns() + if got, _ := getTask("off"); got.NextRun != 0 { + t.Error("une tâche désactivée a été planifiée") + } +} + +// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle +// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle). +func TestRunTaskPresetIntrouvable(t *testing.T) { + testHome(t) + tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"} + if err := saveTask(tk); err != nil { + t.Fatal(err) + } + runTask(tk) + got, _ := getTask("p1") + if got.LastOK || !strings.Contains(got.LastError, "preset") { + t.Errorf("échec de bascule mal signalé : %+v", got) + } +} + +// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son +// compte-rendu précédent — borné, pour ne pas manger le contexte. +func TestTaskContextNote(t *testing.T) { + n := taskContextNote("Veille mails", "") + if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") { + t.Errorf("note incomplète : %q", n) + } + if strings.Contains(n, "dernière exécution") { + t.Error("premier passage : il n'y a pas de compte-rendu à rappeler") + } + long := taskContextNote("t", strings.Repeat("y", reportMax+100)) + if !strings.Contains(long, "dernière exécution") { + t.Error("le compte-rendu précédent doit être réinjecté") + } + if len(long) > reportMax+600 { + t.Errorf("note non bornée : %d caractères", len(long)) + } +} + +// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence +// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire. +func TestHandleTaskSaveValide(t *testing.T) { + testHome(t) + post := func(body string) (int, map[string]any) { + rr := httptest.NewRecorder() + handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return rr.Code, out + } + if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 { + t.Errorf("nom vide accepté : %d %v", code, out) + } + if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 { + t.Errorf("consigne vide acceptée : %d %v", code, out) + } + if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 { + t.Errorf("fréquence illisible acceptée : %d %v", code, out) + } + if len(listTasks()) != 0 { + t.Fatal("une tâche refusée a quand même été enregistrée") + } + + code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`) + if code != 200 || out["ok"] != true { + t.Fatalf("création refusée : %d %v", code, out) + } + id, _ := out["id"].(string) + got, ok := getTask(id) + if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 { + t.Fatalf("tâche mal créée : %+v", got) + } + + // Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce + // qu'on a corrigé une faute dans la consigne). + got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true + if err := saveTask(got); err != nil { + t.Fatal(err) + } + if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 { + t.Fatalf("édition refusée : %d %v", code, out) + } + after, _ := getTask(id) + if after.Name != "n2" || after.Prompt != "p2" { + t.Errorf("édition non appliquée : %+v", after) + } + if after.LastRun != 42 || after.LastReport != "vu hier" { + t.Errorf("historique perdu à l'édition : %+v", after) + } +} + +// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le +// modèle : « génération en cours » sur un fil vide et immobile n'explique rien. +func TestBusyReasonNommeLaTache(t *testing.T) { + testHome(t) + if err := conv.busyReason(); err != ErrBusy { + t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy) + } + conv.mu.Lock() + conv.runningTaskName = "Veille mails" + conv.mu.Unlock() + defer func() { + conv.mu.Lock() + conv.runningTaskName = "" + conv.mu.Unlock() + }() + if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") { + t.Errorf("la tâche n'est pas nommée : %v", err) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 50c83f3..81af19d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2060,6 +2060,48 @@ html[data-files="1"] #files-btn{color:var(--accent)} ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */ .tool-shot{margin-top:8px} .tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in} + +.mcp-tag.mcp-tag-preset{flex:0 1 auto;min-width:2.5em} +.task-consigne{min-height:260px;resize:vertical;line-height:1.6;padding:12px} + +/* Tâches planifiées (repris de l'amont AJEAN) : ligne en cours d'exécution + modale d'édition. */ +.mcp-row.task-running{border-color:var(--accent);background:var(--accent-bg);cursor:default} +.task-dot-run{animation:taskpulse 1.1s ease-in-out infinite} +@keyframes taskpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}} +.task-run-badge{font-size:10px;font-weight:600;color:var(--accent);letter-spacing:.02em} +.task-stop-btn{flex:none;margin:0;padding:3px 10px;font-size:11px;border-radius:6px;background:var(--danger-bg);border:1px solid var(--err);color:var(--err)} +.task-stop-btn:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err);opacity:.85} +/* La modale des tâches emprunte tout le design de l'éditeur de preset (.pe-*) : + même largeur, même mise en carte. */ +#task-modal .modal-card{max-width:520px;overflow:hidden} +#task-modal-status:empty{display:none} +/* Panneau « Dernier résultat » : le compte-rendu complet de l'IA, lisible et + défilable — pas une mini-ligne tronquée. Posé dans une carte .pe-list-like. */ +.task-report{font-size:13px;line-height:1.55;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:11px 14px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word} +.task-report.report-err{border-color:var(--err);color:var(--err)} +.task-report.report-run{color:var(--dim);font-style:italic} +.task-report.report-empty{color:var(--dim)} +/* Compte-rendu rendu en markdown : c'est du HTML de blocs, donc plus de pre-wrap + (sinon les sauts de ligne du HTML généré ajoutent des lignes vides). Styles + alignés sur ceux des bulles de chat (.msg .body). */ +.task-report.md-report{white-space:normal} +.md-report>*{margin:.6em 0} +.md-report>*:first-child{margin-top:0} +.md-report>*:last-child{margin-bottom:0} +.md-report h1,.md-report h2,.md-report h3,.md-report h4{font-weight:600;line-height:1.25;margin:.9em 0 .4em} +.md-report h1{font-size:1.2em}.md-report h2{font-size:1.1em}.md-report h3{font-size:1em}.md-report h4{font-size:.95em} +.md-report ul,.md-report ol{padding-left:1.4em;margin:.5em 0} +.md-report li{margin:.15em 0} +.md-report li>p{margin:0} +.md-report code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;word-break:break-word} +.md-report pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0} +.md-report pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4} +.md-report blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)} +.md-report a{color:var(--accent);text-decoration:none} +.md-report a:hover{text-decoration:underline} +.md-report table{border-collapse:collapse;margin:.5em 0} +.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px} +.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0} +
+ +
+ +
+
+
(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)
+ +
+
+
System prompt
@@ -2703,6 +2762,106 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+
+
Tâches planifiées + +
+ +
+ +
+
+
(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)
+ +
+
+
System prompt
@@ -673,6 +690,106 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+