mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Deux reprises d'AJEAN autour des tâches planifiées. Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory, hors du workspace. Le workspace est jetable — supprimer une discussion emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses scripts normalement. Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script sans charger le modèle ni consommer un token, sa sortie devient le compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne hors du verrou de génération — d'où un registre à part pour l'afficher « en cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale. Outils task_list/create/update/delete : l'IA se donne elle-même rappels et veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000 caractères, avec le palier documenté dans le test. Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et c'est la condition d'un chiffrement de la mémoire à venir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
253 lines
8.3 KiB
Go
253 lines
8.3 KiB
Go
// web_tasks.go — endpoints de pilotage des tâches planifiées (voir tasks.go).
|
|
// Tous derrière la clé de pilotage (enregistrés via api(...) dans newWebMux).
|
|
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// handleTasks : GET → liste des tâches + état de l'interrupteur maître + si le
|
|
// mode agent est actif (sans lui, une tâche n'a aucun outil pour agir).
|
|
//
|
|
// Les horodatages (next_run, last_run) partent en epoch ms BRUTS : c'est le
|
|
// navigateur qui les met en forme, dans le fuseau de l'utilisateur. Les formater
|
|
// ici les figerait dans le fuseau du SERVEUR (souvent UTC), d'où un décalage
|
|
// horaire à l'écran.
|
|
func handleTasks(w http.ResponseWriter, r *http.Request) {
|
|
tasks := listTasks()
|
|
conv.mu.Lock()
|
|
runningID := conv.runningTaskID
|
|
conv.mu.Unlock()
|
|
// Une tâche script tourne HORS du verrou de génération (aucune inférence) :
|
|
// conv ne la voit pas, on complète donc avec le registre des scripts en cours
|
|
// (tasks_script.go), sinon l'UI l'affiche à l'arrêt pendant qu'elle tourne.
|
|
if runningID == "" {
|
|
if sid, _ := scriptRunningAny(); sid != "" {
|
|
runningID = sid
|
|
}
|
|
}
|
|
// Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche.
|
|
presets := []map[string]any{}
|
|
if list, err := ListPresets(); err == nil {
|
|
for _, p := range list {
|
|
presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active})
|
|
}
|
|
}
|
|
// Scripts durables disponibles, pour le sélecteur d'une tâche « script seul ».
|
|
scripts, _ := listScripts()
|
|
if scripts == nil {
|
|
scripts = []scriptInfo{}
|
|
}
|
|
sendJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"tasks": tasks,
|
|
"scripts": scripts,
|
|
"scripts_dir": scriptsDir(),
|
|
"paused": tasksPaused(),
|
|
"agent": agentEnabled(),
|
|
"running_id": runningID,
|
|
"presets": presets,
|
|
// Projets, pour le sélecteur « projet visé » du formulaire de tâche.
|
|
"projects": projectDTOs(),
|
|
// État global mémoire/web, pour proposer des défauts cohérents à la création.
|
|
"mem_on": memMode() != MemOff,
|
|
"web_on": internetEnabled() && crawlReachable(),
|
|
})
|
|
}
|
|
|
|
// handleTaskSave crée ou met à jour une tâche. Valide le schedule (400 sinon) et
|
|
// recalcule NextRun. Un ID vide = création.
|
|
func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Prompt string `json:"prompt"`
|
|
Schedule string `json:"schedule"`
|
|
TZ string `json:"tz"`
|
|
Preset string `json:"preset"`
|
|
Project string `json:"project"`
|
|
Kind string `json:"kind"`
|
|
Script string `json:"script"`
|
|
NoMem bool `json:"no_mem"`
|
|
NoWeb bool `json:"no_web"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Prompt = strings.TrimSpace(req.Prompt)
|
|
req.Schedule = strings.TrimSpace(req.Schedule)
|
|
req.Kind = strings.TrimSpace(req.Kind)
|
|
req.Script = strings.TrimSpace(req.Script)
|
|
if req.Name == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom obligatoire"})
|
|
return
|
|
}
|
|
// Une tâche script exige un script EXISTANT (le vérifier ici évite une tâche
|
|
// planifiée qui échouera silencieusement au premier tic) ; une tâche IA exige
|
|
// une consigne.
|
|
if req.Kind == "script" {
|
|
if req.Script == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "script obligatoire pour une tâche script"})
|
|
return
|
|
}
|
|
if err := scriptExists(req.Script); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
} else if req.Prompt == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "consigne obligatoire"})
|
|
return
|
|
}
|
|
if err := validateSchedule(req.Schedule, req.TZ); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "fréquence invalide : " + err.Error()})
|
|
return
|
|
}
|
|
|
|
var t Task
|
|
if req.ID != "" {
|
|
if cur, ok := getTask(req.ID); ok {
|
|
t = cur // conserve l'historique d'exécution (LastRun/LastReport…)
|
|
} else {
|
|
t.ID = req.ID
|
|
}
|
|
}
|
|
if t.ID == "" {
|
|
t.ID = newTaskID()
|
|
}
|
|
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
|
|
t.TZ, t.Preset = req.TZ, req.Preset
|
|
t.Kind, t.Script = req.Kind, req.Script
|
|
// Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche
|
|
// créée depuis un projet appartient à ce projet — c'est là que sa mémoire et
|
|
// ses trackers l'attendent quand elle tournera (voir runTask).
|
|
if p := strings.TrimSpace(req.Project); p != "" && projectExists(p) {
|
|
t.Project = p
|
|
} else if t.Project == "" {
|
|
t.Project = activeProjectSlug()
|
|
}
|
|
t.NoMem, t.NoWeb = req.NoMem, req.NoWeb
|
|
// Recalcule NextRun si la fréquence a changé (ou à la création).
|
|
if t.Schedule != req.Schedule || t.NextRun == 0 {
|
|
t.NextRun = computeNextRun(req.Schedule, req.TZ, time.Now())
|
|
}
|
|
t.Schedule = req.Schedule
|
|
if err := saveTask(t); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "id": t.ID})
|
|
}
|
|
|
|
// handleTaskDelete supprime une tâche.
|
|
func handleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.ID == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "id manquant"})
|
|
return
|
|
}
|
|
if err := deleteTask(req.ID); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleTaskToggle active/désactive UNE tâche. En l'activant, on (re)pose son
|
|
// NextRun pour qu'elle reparte proprement.
|
|
func handleTaskToggle(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
On bool `json:"on"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
t, ok := getTask(req.ID)
|
|
if !ok {
|
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
|
return
|
|
}
|
|
t.Enabled = req.On
|
|
if req.On {
|
|
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
|
}
|
|
if err := saveTask(t); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleTasksPause pilote l'interrupteur maître ({on:true} = suspendre tout).
|
|
func handleTasksPause(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
On bool `json:"on"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if err := setTasksPaused(req.On); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()})
|
|
}
|
|
|
|
// handleTaskStop arrête la tâche en cours. Une tâche SCRIPT ne passe pas par le
|
|
// verrou de génération : on l'annule via son registre (tasks_script.go). Sinon
|
|
// (tâche IA), on retombe sur conv.Stop, qui annule la génération autonome.
|
|
func handleTaskStop(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.ID != "" && scriptRunStop(req.ID) {
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
conv.Stop()
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleTaskRun lance une tâche MAINTENANT (bouton « tester »). Exécution
|
|
// détachée : on rend la main tout de suite et on rafraîchit l'état à la fin.
|
|
// 409 si une génération est déjà en cours.
|
|
func handleTaskRun(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
t, ok := getTask(req.ID)
|
|
if !ok {
|
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
|
return
|
|
}
|
|
// Tâche script : aucune inférence, donc ni verrou de génération ni moteur
|
|
// chargé à attendre — la refuser parce que le modèle répond ailleurs n'aurait
|
|
// aucun sens.
|
|
if t.Kind == "script" {
|
|
go runTask(t)
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
// Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503.
|
|
conv.mu.Lock()
|
|
busy := conv.Generating
|
|
conv.mu.Unlock()
|
|
if busy {
|
|
sendJSON(w, 409, map[string]any{"ok": false, "error": ErrBusy.Error()})
|
|
return
|
|
}
|
|
if !healthCheck() {
|
|
sendJSON(w, 503, map[string]any{"ok": false, "error": errModelLoading.Error()})
|
|
return
|
|
}
|
|
go runTask(t)
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|