Lot 26 : tâches planifiées tenues par la façade (LOKI_TASKS=rust)

Avec LOKI_TASKS=rust (expérimental), la façade sert les routes
/api/tasks* et fait tourner le planificateur, que le Go ne démarre plus.
Elle exécute elle-même les tâches « script seul » (shell de l'agent,
limite de 10 min, arrêt à la main). Une tâche IA passe par le verrou de
génération et les outils de l'agent, restés au Go : la façade la lui fait
exécuter (/internal/v1/tasks/run), comme son propre planificateur l'aurait
fait. Les notifications partent par la façade si elle les tient, sinon par
le Go (/internal/v1/push/send).

- src/tz.rs : les fuseaux IANA comme le Go (TZif v1/v2 et règle POSIX,
  time.Local, time.Date et ses heures qui sautent ou se répètent) ;
- tasks/cron.rs : les quatre formes de fréquence, avec ParseDuration du Go
  et ses messages d'erreur ; instants sur 128 bits (dates au-delà de
  2262) ;
- tasks/mod.rs : la base, l'exécution, le planificateur (un tic par
  minute, une tâche due par tic) et les sept routes.

Le Go reçoit un crochet de test pour l'heure des tâches (tasksNow).

Correction de la façade : les appels à l'API interne du Go étaient tous
bornés à 10 s. Un outil exécuté par le Go qui dure plus longtemps (page
lente, navigateur, MCP tenu par le Go) était coupé, alors qu'il continuait.
Ces appels sont désormais bornés à 30 min ; une tâche IA est attendue
jusqu'à 24 h.

Parité : parity_tasks_test.go tire du Go un journal rejoué par
tests/tasks.rs, l'heure des tâches fixée pas à pas. Il couvre
l'enregistrement (chaque forme et chaque erreur de fréquence, plusieurs
fuseaux), la bascule, l'interrupteur maître, la suppression, les
lancements et les tics du planificateur. Les mutations (79, dont 77
tuées et 2 équivalentes) ont fait trouver et corriger cinq écarts du
port : dates lointaines, intervalle en jours démesuré, heure qui saute au
printemps, compte-rendu coupé au milieu d'un caractère, noms de fuseau
contenant « .. ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 23:30:03 +00:00
1 parent ed9b2e8c1c
commit 9a0455be8f
30 files changed
+22456 -22

No files matched your search

+1
View File
@@ -240,6 +240,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go |
@@ -0,0 +1,193 @@
# Lot 26 — Tâches planifiées tenues par la façade
## Contexte
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
deux sortes :
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
éphémère ;
- **une tâche « script seul »** : un script du dossier protégé
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
compte-rendu.
Le Go les tient dans plusieurs fichiers :
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
- `tasks_cron.go` : les fréquences ;
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
par tic ;
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
- `web_tasks.go` : sept routes.
Les fréquences prennent quatre formes :
- `@every 2h` : intervalle glissant d'au moins une minute ;
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
- `@once 2026-10-01 16:54` : une seule fois ;
- une expression cron à cinq champs.
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
fuseau inconnu, c'est l'heure locale du serveur.
## Le lot
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
démarre plus. Elle exécute elle-même les tâches script :
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
ouverte ;
- une limite de 10 min ;
- un arrêt par `/api/tasks/stop`.
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
que le Go traite comme son propre planificateur l'aurait fait :
- preset épinglé ;
- projet forcé ;
- `RunAutonomous` ;
- état persisté ;
- notification.
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
Les notifications des tâches script partent par la façade si elle tient les
notifications, sinon par le Go (`/internal/v1/push/send`).
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
ce que le Go fait de la zoneinfo du système :
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
transitions ;
- `time.Local` (`$TZ`, `/etc/localtime`) ;
- `time.Date`, avec la normalisation des débordements ;
- les heures qui sautent ou qui se répètent, résolues comme le Go.
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
Côté Go :
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
routes internes ;
- `web_server.go` : le planificateur n'est pas démarré ;
- `tasksNow` : l'heure des tâches, remplaçable en test.
## Correction de la façade
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
était coupé :
- une page web lente ;
- le navigateur ;
- un serveur MCP tenu par le Go.
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
autre limite que 24 h.
## Ce que les mutations ont corrigé dans le port
Le premier passage des mutations a laissé survivre des cas que le journal ne
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
l'encodage JSON. Le port reculait à une frontière de caractère ;
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
un composant `..`.
## Parité
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
ils apparaissent. Le journal couvre :
- **l'écran** :
- scripts du dossier (sous-dossiers compris) ;
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
- projets, mémoire coupée, internet armé ;
- la tâche en cours pendant un script ;
- **l'enregistrement** :
- corps illisible, sans nom, sans consigne ;
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
caractère interdit ;
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
déjà passée ;
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
qui saute), New York, Calcutta, fuseau inconnu ;
- id inconnu gardé, projet inconnu remplacé par l'actif ;
- modification avec ou sans changement de fréquence ;
- **la bascule, l'interrupteur maître, la suppression** ;
- **les lancements** :
- tâche absente ;
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
- script en succès, en échec, arrêté à la main ;
- script qui échoue après un succès, puis se rétablit ;
- tâche script rangée sans script, au nom refusé, au script disparu ;
- **le planificateur** :
- tic suspendu, tic pendant une optimisation ;
- une tâche IA due qui attend le modèle ;
- une tâche due par tic, dans l'ordre des noms ;
- la tâche « une fois » qui s'éteint ;
- une tâche sans prochain passage (posé sans la lancer) ;
- une fréquence devenue invalide ;
- deux tâches de même nom (rangées par id).
Après chaque étape, le journal enregistre les tâches en base.
Autres tests :
- fuseaux et fréquences confrontés au Go :
- Paris aux deux changements d'heure, New York, Calcutta ;
- avant la première transition, pile à une transition, la table avant la
règle prolongée ;
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
- noms refusés, « UTC » sans base ;
- mois hors bornes, années bissextiles, mois de 30 jours ;
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
364 jours) ;
- le compte-rendu tronqué, les noms de script ;
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
- la durée d'un passage, à une horloge qui avance ;
- la tâche IA en cours vue par l'écran ;
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
- le planificateur tourne à son pas sans demande ;
- sans `LOKI_TASKS`, les routes partent au Go ;
- le Go (`TestFacadeTaches`) :
- les sept routes partent à la façade, et elles seules ;
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
d'enregistré) et sur une tâche script ;
- `/internal/v1/push/send`.
## Mutations
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
équivalentes :
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
Windows) ;
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
Crawl4AI sans adresse, seul cas où les deux diffèrent.
## Écarts assumés
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
rangé.
+2
View File
@@ -179,6 +179,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeEngineImage, "moteur de l'image indisponible : "
case facadeStrataRoutes[path]:
held, unavailable = facadeStrata, "Strata indisponible : "
case facadeTasksRoutes[path]:
held, unavailable = facadeTasks, "tâches indisponibles : "
default:
return h
}
+74
View File
@@ -0,0 +1,74 @@
package loki
import (
"net/http"
"os"
"strings"
)
// facade_tasks.go — tâches planifiées tenues par la façade Rust
// (LOKI_TASKS=rust, lot 26 de la migration ; expérimental, le Go reste le
// défaut) : les routes /api/tasks*, le planificateur et les tâches « script
// seul » (rust/loki-rs/src/tasks). Une tâche IA passe toujours par le verrou
// de génération et les outils de l'agent, qui sont ici : la façade la fait
// exécuter par le Go (/internal/v1/tasks/run). Le planificateur du Go ne
// démarre pas.
const facadeTasksEnv = "LOKI_TASKS"
// facadeTasks : la façade tient-elle les tâches ?
func facadeTasks() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeTasksEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeTasksRoutes : les routes que la façade sert alors.
var facadeTasksRoutes = map[string]bool{
"/api/tasks": true,
"/api/tasks/save": true,
"/api/tasks/delete": true,
"/api/tasks/toggle": true,
"/api/tasks/pause": true,
"/api/tasks/run": true,
"/api/tasks/stop": true,
}
// handleInternalTaskRun (POST {id}) : exécute MAINTENANT la tâche IA d'id
// donné, comme le ferait le planificateur (preset épinglé, projet forcé,
// RunAutonomous, état persisté, notification), et rend la main à la fin.
// {busy:true} : le moteur était occupé ou le modèle pas prêt — la tâche n'a
// pas tourné et sera réessayée.
func handleInternalTaskRun(w http.ResponseWriter, r *http.Request) {
var in struct {
ID string `json:"id"`
}
if !readLeaseBody(w, r, &in) {
return
}
t, ok := getTask(in.ID)
if !ok {
sendJSON(w, http.StatusNotFound, map[string]any{"error": "tâche introuvable"})
return
}
before := t.LastRun
runTask(t)
after, _ := getTask(in.ID)
sendJSON(w, http.StatusOK, map[string]any{"busy": after.LastRun == before})
}
// handleInternalPushSend (POST {title, body}) : une notification à tous les
// abonnés, envoyée en tâche de fond (les tâches script de la façade quand
// elle ne tient pas les notifications).
func handleInternalPushSend(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Body string `json:"body"`
}
if !readLeaseBody(w, r, &in) {
return
}
if hasPushSubs() {
go sendPushToAll(in.Title, in.Body)
}
w.WriteHeader(http.StatusNoContent)
}
+133
View File
@@ -0,0 +1,133 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// Les tâches partent à la façade avec LOKI_TASKS=rust (lot 26) ; le reste,
// non. Le Go exécute pour elle une tâche IA (/internal/v1/tasks/run) et
// envoie ses notifications (/internal/v1/push/send).
func TestFacadeTaches(t *testing.T) {
home := testHome(t)
var mu sync.Mutex
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.Path)
mu.Unlock()
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeTasksEnv, "")
if facadeTasks() {
t.Fatal("tenues sans LOKI_TASKS")
}
for p := range facadeTasksRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_TASKS, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeTasksEnv, " RUST")
if len(facadeTasksRoutes) != 7 {
t.Fatalf("routes : %v", facadeTasksRoutes)
}
for p := range facadeTasksRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
mu.Lock()
n := len(calls)
mu.Unlock()
if n != len(facadeTasksRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/chat/stop", local)(w, httptest.NewRequest("POST", "/api/chat/stop", nil))
if w.Body.String() != "go" {
t.Fatalf("l'arrêt du chat reste au Go : %q", w.Body.String())
}
// /internal/v1/tasks/run : la tâche IA, exécutée comme par le
// planificateur. Modèle pas prêt : elle ne tourne pas (busy).
run := func(body string) (int, map[string]any) {
rec := httptest.NewRecorder()
handleInternalTaskRun(rec, httptest.NewRequest("POST", "/internal/v1/tasks/run", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
if code, _ := run(`{"id":"absente"}`); code != 404 {
t.Fatalf("tâche absente : %d", code)
}
rec := httptest.NewRecorder()
handleInternalTaskRun(rec, httptest.NewRequest("GET", "/internal/v1/tasks/run", nil))
if rec.Code != 405 {
t.Fatalf("GET : %d", rec.Code)
}
if err := saveTask(Task{ID: "ia", Name: "IA", Prompt: "x", Schedule: "@every 1h", Enabled: true}); err != nil {
t.Fatal(err)
}
if code, out := run(`{"id":"ia"}`); code != 200 || out["busy"] != true {
t.Fatalf("tâche IA sans modèle : %d %v", code, out)
}
if tk, _ := getTask("ia"); tk.LastRun != 0 {
t.Fatalf("tâche IA sans modèle : passage enregistré %+v", tk)
}
// /internal/v1/push/send : sans abonné, rien ne part.
rec = httptest.NewRecorder()
handleInternalPushSend(rec, httptest.NewRequest("POST", "/internal/v1/push/send", strings.NewReader(`{"title":"t","body":"b"}`)))
if rec.Code != 204 {
t.Fatalf("push : %d", rec.Code)
}
rec = httptest.NewRecorder()
handleInternalPushSend(rec, httptest.NewRequest("POST", "/internal/v1/push/send", strings.NewReader(`{`)))
if rec.Code != 400 {
t.Fatalf("push illisible : %d", rec.Code)
}
t.Setenv(internalTokenEnv, "")
if facadeTasks() {
t.Fatal("tenues sans jeton interne")
}
// Une tâche script reçue tourne aussi (et n'est pas « occupée »).
if runtime.GOOS != "windows" {
if err := os.MkdirAll(filepath.Join(home, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, "scripts", "ok.sh"), []byte("#!/bin/sh\necho bonjour\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := saveTask(Task{ID: "sc", Name: "Script", Kind: "script", Script: "ok.sh", Schedule: "@every 1h", Enabled: true}); err != nil {
t.Fatal(err)
}
if code, out := run(`{"id":"sc"}`); code != 200 || out["busy"] != false {
t.Fatalf("tâche script : %d %v", code, out)
}
if tk, _ := getTask("sc"); tk.LastRun == 0 || !tk.LastOK || !strings.Contains(tk.LastReport, "bonjour") {
t.Fatalf("tâche script : %+v", tk)
}
}
}
+361
View File
@@ -0,0 +1,361 @@
//go:build linux
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// parity_tasks_test.go — journal de référence des tâches planifiées (lot 26
// de la migration Rust) : l'enregistrement (chaque forme de fréquence, ses
// erreurs, les fuseaux et les changements d'heure), la bascule, l'interrupteur
// maître, la suppression, le lancement d'une tâche script (succès, échec,
// arrêt) ou IA (moteur occupé, modèle pas prêt) et les tics du planificateur,
// l'heure des tâches fixée pas à pas. Rangé dans
// rust/loki-rs/tests/parity_tasks/tasks.json ; tests/tasks.rs le rejoue par la
// façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTaches
const parityTasksFile = "../../rust/loki-rs/tests/parity_tasks/tasks.json"
type parityTasksStep struct {
Name string `json:"name"`
Now string `json:"now,omitempty"` // RFC 3339 : l'heure des tâches
Files map[string]string `json:"files,omitempty"`
Config map[string]string `json:"config,omitempty"`
State map[string]string `json:"state,omitempty"`
Generating bool `json:"generating,omitempty"`
Tick bool `json:"tick,omitempty"` // un tic du planificateur, avant la requête
Tune bool `json:"tune,omitempty"` // une optimisation tient le moteur pendant l'étape
PutTask string `json:"put_task,omitempty"` // une tâche écrite telle quelle en base
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body string `json:"body,omitempty"`
WaitRunning string `json:"wait_running,omitempty"` // « {{IDn}} » : attendre qu'elle tourne
WaitDone string `json:"wait_done,omitempty"` // « {{IDn}} » : attendre la fin du passage
Status int `json:"status,omitempty"`
Out any `json:"out,omitempty"`
Tasks any `json:"tasks"`
}
var (
parityTaskDurRe = regexp.MustCompile(`exit: (-?[0-9]+) · [^\n"\\]*`)
parityTaskIDRe = regexp.MustCompile(`\b[0-9a-f]{12}\b`)
parityTaskCreatedRe = regexp.MustCompile(`"created":[0-9]+`)
)
func TestPariteTaches(t *testing.T) {
home := testHome(t)
oldNow := tasksNow
var now time.Time
tasksNow = func() time.Time { return now }
t.Cleanup(func() { tasksNow = oldNow })
// Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans
// l'ordre où ils apparaissent.
ids := map[string]string{}
var order []string
mask := func(s string) string {
s = parityTaskIDRe.ReplaceAllStringFunc(s, func(id string) string {
if _, ok := ids[id]; !ok {
order = append(order, id)
ids[id] = "{{ID" + strconv.Itoa(len(order)) + "}}"
}
return ids[id]
})
s = strings.ReplaceAll(s, home, "{{HOME}}")
// La date du projet par défaut, posée à la lecture (heure réelle).
s = parityTaskCreatedRe.ReplaceAllString(s, `"created":"{{T}}"`)
return parityTaskDurRe.ReplaceAllString(s, "exit: $1 · {{DUR}}")
}
unmask := func(s string) string {
for id, m := range ids {
s = strings.ReplaceAll(s, m, id)
}
return strings.ReplaceAll(s, "{{HOME}}", home)
}
handlers := map[string]http.HandlerFunc{
"/api/tasks": handleTasks,
"/api/tasks/save": handleTaskSave,
"/api/tasks/delete": handleTaskDelete,
"/api/tasks/toggle": handleTaskToggle,
"/api/tasks/pause": handleTasksPause,
"/api/tasks/run": handleTaskRun,
"/api/tasks/stop": handleTaskStop,
}
call := func(method, path, body string) (int, any) {
rec := httptest.NewRecorder()
handlers[path](rec, httptest.NewRequest(method, path, strings.NewReader(body)))
var out any
if err := json.Unmarshal([]byte(mask(rec.Body.String())), &out); err != nil {
t.Fatalf("%s : corps illisible %q", path, rec.Body.String())
}
return rec.Code, out
}
snapshot := func() any {
b, _ := json.Marshal(listTasks())
var v any
_ = json.Unmarshal([]byte(mask(string(b))), &v)
return v
}
lastRun := func(id string) int64 {
tk, _ := getTask(id)
return tk.LastRun
}
var steps []parityTasksStep
run := func(st parityTasksStep) {
if st.Now != "" {
n, err := time.Parse(time.RFC3339, st.Now)
if err != nil {
t.Fatal(err)
}
now = n
}
files := map[string]string{}
for k, v := range st.Files {
files[k] = unmask(v)
}
parityApplyFiles(t, home, files)
for k, v := range st.Config {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
for k, v := range st.State {
if err := putStr(bkState, k, v); err != nil {
t.Fatal(err)
}
}
if st.PutTask != "" {
var tk Task
if err := json.Unmarshal([]byte(st.PutTask), &tk); err != nil {
t.Fatal(err)
}
if err := putBytes(bkTasks, tk.ID, []byte(st.PutTask)); err != nil {
t.Fatal(err)
}
}
if st.Generating {
conv.mu.Lock()
conv.Generating = true
conv.mu.Unlock()
}
var lock *tuneLock
if st.Tune {
l, err := tuneLockAcquire("web", false)
if err != nil {
t.Fatal(err)
}
lock = l
}
if st.Tick {
tickTasks(tasksNow())
}
var before int64
if st.WaitDone != "" {
before = lastRun(unmask(st.WaitDone))
}
if st.Path != "" {
st.Status, st.Out = call(st.Method, st.Path, unmask(st.Body))
}
if lock != nil {
lock.release()
}
if st.Generating {
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
}
if st.WaitRunning != "" {
deadline := time.Now().Add(10 * time.Second)
for {
_, out := call("GET", "/api/tasks", "")
if out.(map[string]any)["running_id"] == st.WaitRunning {
break
}
if time.Now().After(deadline) {
t.Fatalf("%s : la tâche ne tourne pas", st.Name)
}
time.Sleep(10 * time.Millisecond)
}
}
if st.WaitDone != "" {
id := unmask(st.WaitDone)
deadline := time.Now().Add(20 * time.Second)
for lastRun(id) == before {
if time.Now().After(deadline) {
t.Fatalf("%s : le passage ne finit pas", st.Name)
}
time.Sleep(10 * time.Millisecond)
}
// Le registre des scripts est vidé juste après l'écriture.
for {
if sid, _ := scriptRunningAny(); sid == "" {
break
}
time.Sleep(5 * time.Millisecond)
}
}
st.Tasks = snapshot()
steps = append(steps, st)
}
save := func(name, extra string) string {
return `{"name":"` + name + `","prompt":"Fais le point.","enabled":true` + extra + `}`
}
for _, st := range []parityTasksStep{
{Name: "liste vide", Now: "2026-03-28T12:00:00Z", Files: map[string]string{
"scripts/ok.sh": "#!/bin/sh\necho bonjour\n",
"scripts/echec.sh": "#!/bin/sh\necho oups >&2\nexit 2\n",
"scripts/lent.sh": "#!/bin/sh\nsleep 30\n",
"scripts/sous/rang.sh": "#!/bin/sh\necho rangé\n",
}, Method: "GET", Path: "/api/tasks"},
{Name: "agent actif, interrupteur", State: map[string]string{"agent": "1"}, Method: "GET", Path: "/api/tasks"},
{Name: "mémoire coupée, internet armé", Config: map[string]string{"MEM_MODE": "off"}, State: map[string]string{"internet": "1"}, Method: "GET", Path: "/api/tasks"},
// Enregistrement : erreurs.
{Name: "corps illisible", Method: "POST", Path: "/api/tasks/save", Body: `{"enabled":"oui"}`},
{Name: "sans nom", Method: "POST", Path: "/api/tasks/save", Body: `{"name":" ","prompt":"x","schedule":"@every 1h"}`},
{Name: "sans consigne", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","schedule":"@every 1h"}`},
{Name: "script sans nom de script", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","schedule":"@every 1h"}`},
{Name: "script absent", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"absent.sh","schedule":"@every 1h"}`},
{Name: "script : dossier", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"sous","schedule":"@every 1h"}`},
{Name: "script : remontée", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"../x.sh","schedule":"@every 1h"}`},
{Name: "script : chemin absolu", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"/etc/x","schedule":"@every 1h"}`},
{Name: "script : séparateur Windows", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"\\x.sh","schedule":"@every 1h"}`},
{Name: "script : caractère interdit", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"a;b","schedule":"@every 1h"}`},
{Name: "fréquence vide", Method: "POST", Path: "/api/tasks/save", Body: save("x", ``)},
{Name: "intervalle trop court", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 30s"`)},
{Name: "intervalle illisible", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every deux heures"`)},
{Name: "intervalle sans unité", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 5"`)},
{Name: "jours invalides", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 0d"`)},
{Name: "heure hors bornes", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 1d@24:00"`)},
{Name: "heure sans deux-points", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 1d@9h"`)},
{Name: "cron à 4 champs", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 * *"`)},
{Name: "cron : minute hors bornes", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"61 9 * * *"`)},
{Name: "cron : jour en lettres", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 * * sun"`)},
{Name: "cron : plage inversée", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 5-1 * *"`)},
{Name: "cron : pas nul", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"*/0 * * * *"`)},
{Name: "cron jamais satisfait", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 0 30 2 *"`)},
{Name: "une fois, date illisible", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@once 2026-04-31 10:00"`)},
{Name: "une fois, déjà passé", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@once 2026-03-28 13:00","tz":"Europe/Paris"`)},
// Enregistrement : chaque forme.
{Name: "toutes les 2 h", Method: "POST", Path: "/api/tasks/save", Body: save("B toutes les 2 h", `,"schedule":"@every 2h"`)},
{Name: "toutes les 1 h 30", Method: "POST", Path: "/api/tasks/save", Body: save("C une heure et demie", `,"schedule":"@EVERY 1.5h","enabled":false`)},
{Name: "tous les jours à 23:00, Paris", Method: "POST", Path: "/api/tasks/save", Body: save("D chaque soir", `,"schedule":"@every 1d@23:00","tz":"Europe/Paris"`)},
{Name: "tous les 3 jours à 9:30, New York", Method: "POST", Path: "/api/tasks/save", Body: save("E tous les trois jours", `,"schedule":"@every 3d@9:30","tz":"America/New_York"`)},
{Name: "en semaine à 9 h, Calcutta", Method: "POST", Path: "/api/tasks/save", Body: save("F semaine", `,"schedule":"0 9 * * 1-5","tz":"Asia/Kolkata"`)},
{Name: "chaque nuit à 2:30, Paris (heure qui saute)", Method: "POST", Path: "/api/tasks/save", Body: save("G nuit", `,"schedule":"30 2 * * *","tz":"Europe/Paris"`)},
{Name: "toutes les 15 min, fuseau inconnu", Method: "POST", Path: "/api/tasks/save", Body: save("H quart d'heure", `,"schedule":"*/15 * * * *","tz":"Nulle/Part"`)},
{Name: "le dimanche (7)", Method: "POST", Path: "/api/tasks/save", Body: save("I dimanche", `,"schedule":"0 8 * * 7"`)},
{Name: "une seule fois", Method: "POST", Path: "/api/tasks/save", Body: save("J une fois", `,"schedule":"@once 2026-03-29 03:30","tz":"Europe/Paris"`)},
{Name: "tâche script", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"A script","kind":"script","script":" ok.sh ","schedule":"@every 1h","enabled":true}`},
{Name: "tâche script en échec", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"K échec","kind":"script","script":"echec.sh","schedule":"@every 1h","enabled":true}`},
{Name: "tâche script lente", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"L lente","kind":"script","script":"lent.sh","schedule":"@every 1h"}`},
{Name: "script une seule fois", Method: "POST", Path: "/api/tasks/save",
Body: `{"name":"N script une fois","kind":"script","script":"ok.sh","schedule":"@once 2026-03-29 03:30","tz":"Europe/Paris","enabled":true}`},
{Name: "tâche avec id inconnu", Method: "POST", Path: "/api/tasks/save", Body: `{"id":"zz","name":"M imposée","prompt":"x","schedule":"@every 1h","project":"inconnu","no_mem":true,"no_web":true}`},
{Name: "liste", Method: "GET", Path: "/api/tasks"},
// Modification : la fréquence inchangée garde le prochain passage.
{Name: "renommer sans changer la fréquence", Now: "2026-03-28T12:30:00Z", Method: "POST", Path: "/api/tasks/save",
Body: `{"id":"{{ID1}}","name":"B renommée","prompt":"Autre.","schedule":"@every 2h","enabled":true}`},
{Name: "changer la fréquence", Method: "POST", Path: "/api/tasks/save",
Body: `{"id":"{{ID1}}","name":"B renommée","prompt":"Autre.","schedule":"@every 3h","enabled":true}`},
// Bascule, interrupteur maître, suppression.
{Name: "bascule d'une tâche absente", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"absente","on":true}`},
{Name: "réactiver", Now: "2026-03-28T13:00:00Z", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"{{ID2}}","on":true}`},
{Name: "désactiver", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"{{ID2}}","on":false}`},
{Name: "suspendre tout", Method: "POST", Path: "/api/tasks/pause", Body: `{"on":true}`},
{Name: "liste suspendue", Method: "GET", Path: "/api/tasks"},
{Name: "tic suspendu", Now: "2026-03-28T23:00:00Z", Tick: true},
{Name: "reprendre", Method: "POST", Path: "/api/tasks/pause", Body: `{"on":false}`},
{Name: "interrupteur rangé à 0 : pas suspendu", State: map[string]string{"tasks_paused": "0"}, Method: "GET", Path: "/api/tasks"},
{Name: "suppression sans id", Method: "POST", Path: "/api/tasks/delete", Body: `{}`},
{Name: "suppression", Method: "POST", Path: "/api/tasks/delete", Body: `{"id":"zz"}`},
// Lancements.
{Name: "lancer une tâche absente", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"absente"}`},
{Name: "lancer une tâche IA, moteur occupé", Generating: true, Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID1}}"}`},
{Name: "lancer une tâche IA, modèle pas prêt", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID1}}"}`},
{Name: "lancer le script", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"},
{Name: "lancer le script en échec", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID11}}"}`, WaitDone: "{{ID11}}"},
{Name: "lancer le script lent", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID12}}"}`, WaitRunning: "{{ID12}}"},
{Name: "liste pendant le script", Method: "GET", Path: "/api/tasks"},
{Name: "arrêter le script", Method: "POST", Path: "/api/tasks/stop", Body: `{"id":"{{ID12}}"}`, WaitDone: "{{ID12}}"},
{Name: "arrêter sans tâche script", Method: "POST", Path: "/api/tasks/stop", Body: `{}`},
{Name: "le script qui réussissait échoue", Now: "2026-03-28T23:05:00Z", Files: map[string]string{"scripts/ok.sh": "#!/bin/sh\necho perdu\nexit 3\n"},
Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"},
{Name: "tâche script sans script", Now: "2026-03-28T23:01:00Z", PutTask: `{"id":"vide","name":"S vide","kind":"script","schedule":"@every 1h"}`,
Method: "POST", Path: "/api/tasks/run", Body: `{"id":"vide"}`, WaitDone: "vide"},
{Name: "tâche script au nom refusé", PutTask: `{"id":"remonte","name":"S remonte","kind":"script","script":"../x.sh","schedule":"@every 1h"}`,
Method: "POST", Path: "/api/tasks/run", Body: `{"id":"remonte"}`, WaitDone: "remonte"},
{Name: "tâche script au script disparu", PutTask: `{"id":"disparu","name":"S disparu","kind":"script","script":"parti.sh","schedule":"@every 1h"}`,
Method: "POST", Path: "/api/tasks/run", Body: `{"id":"disparu"}`, WaitDone: "disparu"},
{Name: "puis réussit de nouveau", Now: "2026-03-28T23:10:00Z", Files: map[string]string{"scripts/ok.sh": "#!/bin/sh\necho bonjour\n"},
Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"},
// Planificateur : les tâches IA (jamais exécutées sans modèle, elles
// bloqueraient la file) d'abord désactivées.
{Name: "tic : une tâche IA due attend le modèle", Now: "2026-03-28T16:00:00Z", Tick: true},
} {
run(st)
}
for i := 1; i <= 9; i++ {
run(parityTasksStep{Name: "désactiver la tâche IA " + strconv.Itoa(i), Method: "POST", Path: "/api/tasks/toggle",
Body: `{"id":"{{ID` + strconv.Itoa(i) + `}}","on":false}`})
}
for _, st := range []parityTasksStep{
{Name: "tic pendant une optimisation : rien ne part", Now: "2026-03-29T00:30:00Z", Tune: true, Tick: true},
{Name: "tic : la première tâche due tourne", Now: "2026-03-29T00:30:00Z", Tick: true},
{Name: "tic : la suivante", Tick: true},
{Name: "tic : rien de dû", Tick: true},
{Name: "tic : de nouveau dû", Now: "2026-03-29T01:31:00Z", Tick: true},
{Name: "tic : la suivante, encore", Tick: true},
{Name: "tic : la tâche une fois tourne puis s'éteint", Tick: true},
{Name: "tic : plus rien", Tick: true},
{Name: "tâche sans prochain passage", Now: "2026-03-29T02:00:00Z",
PutTask: `{"id":"manuelle","name":"O manuelle","kind":"script","script":"ok.sh","schedule":"0 4 * * *","tz":"UTC","enabled":true}`, Tick: true},
{Name: "tâche à la fréquence devenue invalide", PutTask: `{"id":"cassee","name":"P cassée","kind":"script","script":"ok.sh","schedule":"@every 10s","enabled":true}`, Tick: true},
{Name: "même nom, ids dans l'ordre", PutTask: `{"id":"r2","name":"R double","kind":"script","script":"ok.sh","schedule":"@every 1h"}`},
{Name: "même nom, second", PutTask: `{"id":"r1","name":"R double","kind":"script","script":"ok.sh","schedule":"@every 1h"}`},
{Name: "liste finale", Method: "GET", Path: "/api/tasks"},
} {
run(st)
}
got, err := parityJSON(map[string]any{"steps": steps})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll("../../rust/loki-rs/tests/parity_tasks", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityTasksFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityTasksFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les tâches ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTaches,\n"+
"puis cargo test --test tasks dit si le port Rust suit.\n--- obtenu ---\n%s", parityTasksFile, got)
}
}
+4
View File
@@ -143,6 +143,10 @@ func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
// tasksNow : l'heure des tâches (planification, passages), remplaçable par
// les tests (journal de parité des tâches).
var tasksNow = time.Now
// 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
+1 -1
View File
@@ -129,7 +129,7 @@ func cutSuffix(s, suffix string) (string, bool) {
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
func validateSchedule(schedule, tz string) error {
_, err := nextAfter(schedule, tz, time.Now())
_, err := nextAfter(schedule, tz, tasksNow())
return err
}
+2 -2
View File
@@ -155,7 +155,7 @@ 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()
start := tasksNow()
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
@@ -273,7 +273,7 @@ func ensureTaskPreset(id string) error {
// 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()
now := tasksNow()
cur, ok := getTask(id)
if !ok {
return // supprimée entre-temps : rien à écrire
+2 -2
View File
@@ -27,7 +27,7 @@ func taskSchedLoop() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
tickTasks(time.Now())
tickTasks(tasksNow())
}
}
@@ -35,7 +35,7 @@ func taskSchedLoop() {
// 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()
now := tasksNow()
for _, t := range listTasks() {
if t.Enabled && t.NextRun == 0 {
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
+1 -1
View File
@@ -77,7 +77,7 @@ func scriptRunStop(id string) bool {
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
// pour une tâche IA), mais sans aucune inférence.
func runScriptTask(t Task) {
start := time.Now()
start := tasksNow()
name := strings.TrimSpace(t.Script)
if name == "" {
+4
View File
@@ -129,6 +129,10 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalProjectSwitch(w, r)
case "/internal/v1/project/drop-convs":
handleInternalProjectDropConvs(w, r)
case "/internal/v1/tasks/run":
handleInternalTaskRun(w, r)
case "/internal/v1/push/send":
handleInternalPushSend(w, r)
default:
http.NotFound(w, r)
}
+5 -2
View File
@@ -151,8 +151,11 @@ func newWebMux() *http.ServeMux {
// attend le déverrouillage (voir handleMemUnlock). Idempotent.
resumeMemMigration()
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
StartTaskScheduler()
// qui détient la conversation et le modèle (idempotent, cf. sync.Once) — sauf
// quand la façade tient les tâches (facade_tasks.go) : c'est elle qui planifie.
if !facadeTasks() {
StartTaskScheduler()
}
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
startSpecAttemptWatch()
+2 -3
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
)
// handleTasks : GET → liste des tâches + état de l'interrupteur maître + si le
@@ -134,7 +133,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
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.NextRun = computeNextRun(req.Schedule, req.TZ, tasksNow())
}
t.Schedule = req.Schedule
if err := saveTask(t); err != nil {
@@ -176,7 +175,7 @@ func handleTaskToggle(w http.ResponseWriter, r *http.Request) {
}
t.Enabled = req.On
if req.On {
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
t.NextRun = computeNextRun(t.Schedule, t.TZ, tasksNow())
}
if err := saveTask(t); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+4
View File
@@ -62,6 +62,9 @@ pub struct Ctx {
/// Strata (écran, réglages, installation) tenu par la façade
/// (`LOKI_STRATA=rust`, avec les backends).
pub strata: Option<std::sync::Arc<crate::strata::api::Strata>>,
/// Tâches planifiées (routes, planificateur, tâches script) tenues par la
/// façade (`LOKI_TASKS=rust`).
pub tasks: Option<std::sync::Arc<crate::tasks::Tasks>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -86,6 +89,7 @@ impl Ctx {
|| self.backends.is_some()
|| self.engineimg.is_some()
|| self.strata.is_some()
|| self.tasks.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+2
View File
@@ -44,6 +44,8 @@ pub mod strata;
pub mod sysinfo;
pub mod system;
pub mod sysupdate;
pub mod tasks;
pub mod tools;
pub mod tz;
pub mod ui;
pub mod update;
+7
View File
@@ -180,6 +180,9 @@ fn serve() -> ExitCode {
if held_by_facade("LOKI_STRATA") && !backends_rust {
eprintln!("[loki-rs] Strata tenu par le Go (LOKI_BACKENDS=rust requis)");
}
// LOKI_TASKS=rust : les tâches planifiées (routes, planificateur,
// tâches script) ; une tâche IA reste exécutée par le Go.
let tasks_rust = held_by_facade("LOKI_TASKS");
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -214,6 +217,7 @@ fn serve() -> ExitCode {
("LOKI_BACKENDS", backends_rust),
("LOKI_ENGINE_IMAGE", engineimg_rust),
("LOKI_STRATA", strata_rust),
("LOKI_TASKS", tasks_rust),
];
for (var, on) in held {
if on {
@@ -345,6 +349,8 @@ fn serve() -> ExitCode {
loki_rs::backends::probe::Probe::system(),
))
});
let tasks = tasks_rust
.then(|| std::sync::Arc::new(loki_rs::tasks::Tasks::new(engine_paths.home.clone())));
let cfg = proxy::Config {
backend,
internal_token: token,
@@ -366,6 +372,7 @@ fn serve() -> ExitCode {
backends,
engineimg,
strata,
tasks,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
+1 -1
View File
@@ -813,7 +813,7 @@ impl Memory {
// --- mode mémoire ---------------------------------------------------
/// `memMode` : mode du projet actif, repli sur `MEM_MODE`.
async fn mem_mode(store: &Store) -> &'static str {
pub(crate) async fn mem_mode(store: &Store) -> &'static str {
let active = active_project(store).await;
let projects = list_projects(store).await;
if let Some(p) = projects.iter().find(|p| p.slug == active)
+9 -1
View File
@@ -263,6 +263,14 @@ async fn delete_project(ctx: &Ctx, slug: &str) -> Result<(), Fail> {
/// `projectDTOs` : le registre et ses comptes, plus le projet actif.
async fn listing(ctx: &Ctx) -> Value {
let active = active_project(&ctx.store).await;
let projects = project_dtos(ctx).await;
json!({ "ok": true, "projects": projects, "active": active })
}
/// `projectDTOs` : les projets avec leurs compteurs (discussions, pages,
/// trackers) et le drapeau du projet actif.
pub(crate) async fn project_dtos(ctx: &Ctx) -> Vec<Value> {
let store = &ctx.store;
let active = active_project(store).await;
let mut convs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
@@ -311,7 +319,7 @@ async fn listing(ctx: &Ctx) -> Value {
d
})
.collect();
json!({ "ok": true, "projects": projects, "active": active })
projects
}
/// Les routes servies par ce module.
+28
View File
@@ -103,6 +103,20 @@ pub struct Config {
/// Strata (machine, réglages, installation) tenu par la façade
/// (`LOKI_STRATA=rust`) ; `None` : au Go.
pub strata: Option<Arc<crate::strata::api::Strata>>,
/// Tâches planifiées (routes, planificateur, tâches script) tenues par la
/// façade (`LOKI_TASKS=rust`) ; `None` : au Go.
pub tasks: Option<Arc<crate::tasks::Tasks>>,
}
/// Arrête la tâche de fond tenue à sa sortie de portée.
struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
if let Some(h) = &self.0 {
h.abort();
}
}
}
struct Proxy {
@@ -145,9 +159,18 @@ pub async fn serve(
backends: cfg.backends,
engineimg: cfg.engineimg,
strata: cfg.strata,
tasks: cfg.tasks,
internal_token: cfg.internal_token.clone(),
},
});
// Le planificateur des tâches tenues ici, arrêté avec la façade.
let _sched = AbortOnDrop(
proxy
.ctx
.tasks
.clone()
.map(|t| tokio::spawn(crate::tasks::sched_loop(proxy.ctx.clone(), t))),
);
tokio::pin!(shutdown);
loop {
tokio::select! {
@@ -259,6 +282,11 @@ impl Proxy {
{
return crate::strata::api::serve(&self.ctx, s, req, from_go).await;
}
if let Some(t) = &self.ctx.tasks
&& crate::tasks::handles(path)
{
return crate::tasks::serve(&self.ctx, t, req, from_go).await;
}
if let Some(b) = &self.ctx.backends
&& crate::backends::handles(path)
{
+58 -7
View File
@@ -18,6 +18,12 @@ pub const BK_CONFIG: &str = "config";
pub const BK_PREFS: &str = "prefs";
pub const BK_STATE: &str = "state";
/// Délai d'un appel à l'API interne (base, chiffrement, petites actions).
const CALL_LIMIT: Duration = Duration::from_secs(10);
/// Délai d'un outil exécuté par le Go : le plus long des siens (navigateur,
/// pages web, MCP) reste bien en deçà.
const TOOL_LIMIT: Duration = Duration::from_secs(30 * 60);
#[derive(Clone)]
pub struct Store {
client: Client<HttpConnector, Full<Bytes>>,
@@ -39,6 +45,18 @@ impl Store {
method: Method,
path: &str,
body: Vec<u8>,
) -> Result<(StatusCode, Bytes), String> {
self.call_within(method, path, body, CALL_LIMIT).await
}
/// Comme `call`, avec le délai donné (un appel qui dure : un outil, une
/// tâche IA exécutés par le Go).
async fn call_within(
&self,
method: Method,
path: &str,
body: Vec<u8>,
limit: Duration,
) -> Result<(StatusCode, Bytes), String> {
let req = Request::builder()
.method(method)
@@ -57,7 +75,7 @@ impl Store {
.to_bytes();
Ok((status, body))
};
tokio::time::timeout(Duration::from_secs(10), fut)
tokio::time::timeout(limit, fut)
.await
.map_err(|_| "store : délai dépassé".to_string())?
}
@@ -104,6 +122,26 @@ impl Store {
Ok((status, v))
}
/// POST JSON à l'API interne, sans autre délai que `limit` : un appel qui
/// dure (une tâche IA exécutée par le Go).
pub async fn internal_post_within(
&self,
path: &str,
body: &serde_json::Value,
limit: Duration,
) -> Result<(StatusCode, serde_json::Value), String> {
let (status, bytes) = self
.call_within(
Method::POST,
path,
serde_json::to_vec(body).unwrap_or_default(),
limit,
)
.await?;
let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
Ok((status, v))
}
/// Scelle un blob avec la clé de la mémoire (gardée par le Go). Clair
/// rendu tel quel si le chiffrement est coupé.
pub async fn seal(&self, plain: Vec<u8>) -> Result<Vec<u8>, CryptError> {
@@ -161,12 +199,25 @@ impl Store {
args: &serde_json::Value,
caps: &serde_json::Value,
) -> Result<serde_json::Value, String> {
self.internal_json(
Method::POST,
"/internal/v1/tool/exec",
Some(&serde_json::json!({ "name": name, "args": args, "caps": caps })),
)
.await
let body = serde_json::json!({ "name": name, "args": args, "caps": caps });
let (status, bytes) = self
.call_within(
Method::POST,
"/internal/v1/tool/exec",
serde_json::to_vec(&body).unwrap_or_default(),
TOOL_LIMIT,
)
.await?;
if !status.is_success() {
return Err(format!(
"/internal/v1/tool/exec : {status} {}",
String::from_utf8_lossy(&bytes)
));
}
if bytes.is_empty() {
return Ok(serde_json::Value::Null);
}
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
/// Archive un bloc verbatim pour l'outil recall (`archiveRecallBlock`,
+1 -1
View File
@@ -84,7 +84,7 @@ impl System {
/// `crawlReachable` : le serveur Crawl4AI répond-il (moins de 500 sur
/// `/health` ou `/`, deux secondes en tout) ? Le moteur intégré n'a rien à
/// joindre.
async fn reachable(&self, cfg: &Cfg, key: &str) -> bool {
pub(crate) async fn reachable(&self, cfg: &Cfg, key: &str) -> bool {
if web_engine(cfg) == ENGINE_GO {
return true;
}
+693
View File
@@ -0,0 +1,693 @@
//! Planification des tâches, résolution à la minute — port de
//! `tasks_cron.go`. Quatre formes :
//!
//! - « @every 30m », « @every 2h » : intervalle glissant (`time.ParseDuration`
//! du Go, borné à ≥ 1 minute) ;
//! - « @every 1d@23:00 » : tous les N jours, ancré à une heure murale ;
//! - « @once 2026-10-01 16:54 » : une seule fois ;
//! - expression cron 5 champs (*, a, a-b, a,b, */n, a-b/n).
//!
//! Les instants sont des nanosecondes Unix sur 128 bits (une date « @once »
//! peut dépasser 2262, que le Go accepte) ; l'heure murale est celle du
//! fuseau de l'utilisateur ([`crate::tz`], la zoneinfo du système comme le
//! Go).
use std::collections::BTreeSet;
use crate::gojson::quote;
use crate::tz::{Civil, Location};
const NS: i64 = 1_000_000_000;
const MINUTE: i64 = 60 * NS;
const HOUR: i64 = 60 * MINUTE;
const NS128: i128 = NS as i128;
/// `loc` : fuseau IANA, vide ou introuvable → heure locale du serveur.
pub fn loc(tz: &str) -> Location {
loc_or(tz, Location::local)
}
/// `loc`, l'heure locale donnée.
fn loc_or(tz: &str, local: fn() -> Location) -> Location {
if tz.is_empty() {
return local();
}
Location::load(tz).unwrap_or_else(|_| local())
}
fn civil_of(l: &Location, ns: i128) -> Civil {
l.civil(ns.div_euclid(NS128) as i64)
}
/// `cutPrefix` : préfixe insensible à la casse.
pub fn cut_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if s.len() >= prefix.len()
&& s.is_char_boundary(prefix.len())
&& s[..prefix.len()].eq_ignore_ascii_case(prefix)
{
Some(&s[prefix.len()..])
} else {
None
}
}
/// `isOnce`.
pub fn is_once(schedule: &str) -> bool {
cut_prefix(schedule.trim(), "@once").is_some()
}
/// `nextAfter` : le prochain déclenchement (ns), strictement après `from`
/// (ns).
pub fn next_after(schedule: &str, tz: &str, from: i64) -> Result<i128, String> {
let from = i128::from(from);
let s = schedule.trim();
if s.is_empty() {
return Err("fréquence vide".into());
}
if let Some(rest) = cut_prefix(s, "@once") {
let l = loc(tz);
let Some(at) = parse_ymd_hm(rest.trim(), &l) else {
return Err("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)".into());
};
if at <= from {
return Err(format!(
"{} est déjà passé (il est {})",
civil_of(&l, at).ymd_hm(),
civil_of(&l, from).ymd_hm()
));
}
return Ok(at);
}
if let Some(rest) = cut_prefix(s, "@every") {
return next_every(rest.trim(), tz, from);
}
next_cron(s, tz, from)
}
/// `time.ParseInLocation("2006-01-02 15:04", s, l)` : l'instant (ns), ou
/// rien si la chaîne ne suit pas la forme.
fn parse_ymd_hm(s: &str, l: &Location) -> Option<i128> {
let b = s.as_bytes();
let digits = |r: &[u8]| r.iter().all(u8::is_ascii_digit);
// 2006 : 4 chiffres ; -01 et -02 : 2 chiffres ; 15 : 1 ou 2 ; :04 : 2.
if b.len() < 4 + 1 + 2 + 1 + 2 + 1 + 1 + 1 + 2 || !digits(&b[..4]) {
return None;
}
let year: i64 = s[..4].parse().ok()?;
let rest = s[4..].strip_prefix('-')?;
if rest.len() < 2 || !digits(&rest.as_bytes()[..2]) {
return None;
}
let month: i64 = rest[..2].parse().ok()?;
let rest = rest[2..].strip_prefix('-')?;
if rest.len() < 2 || !digits(&rest.as_bytes()[..2]) {
return None;
}
let day: i64 = rest[..2].parse().ok()?;
let rest = rest[2..].strip_prefix(' ')?;
let hl = rest.bytes().take(2).take_while(u8::is_ascii_digit).count();
if hl == 0 {
return None;
}
let hour: i64 = rest[..hl].parse().ok()?;
let rest = rest[hl..].strip_prefix(':')?;
if rest.len() != 2 || !digits(rest.as_bytes()) {
return None;
}
let minute: i64 = rest.parse().ok()?;
if !(1..=12).contains(&month) || hour > 23 || minute > 59 {
return None;
}
let dim = days_in(month, year);
if day < 1 || day > dim {
return None;
}
Some(i128::from(l.date(year, month, day, hour, minute, 0)) * NS128)
}
fn days_in(m: i64, y: i64) -> i64 {
match m {
2 => {
if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
/// `nextEvery`.
fn next_every(spec: &str, tz: &str, from: i128) -> Result<i128, String> {
let (dur_part, time_part) = match spec.split_once('@') {
Some((d, t)) => (d.trim(), Some(t)),
None => (spec.trim(), None),
};
if let Some(n) = dur_part.strip_suffix('d') {
let days = n
.parse::<i64>()
.ok()
.filter(|d| *d >= 1)
.ok_or_else(|| format!("nombre de jours invalide : {}", quote(dur_part)))?;
let Some(tp) = time_part else {
// time.Duration(days) * 24 * time.Hour : déborde comme le Go.
return Ok(from + i128::from(days.wrapping_mul(24).wrapping_mul(HOUR)));
};
let (hh, mm) = parse_hhmm(tp)?;
let l = loc(tz);
let now = civil_of(&l, from);
let mut cand = l.date(now.year, now.month, now.day, hh, mm, 0);
// cand.AddDate(0, 0, days) : l'heure murale DU CANDIDAT, jour après
// jour (une heure qui saute au printemps reste décalée ensuite).
while i128::from(cand) * NS128 <= from {
let c = l.civil(cand);
cand = l.date(c.year, c.month, c.day + days, c.hour, c.minute, c.second);
}
return Ok(i128::from(cand) * NS128);
}
let d = parse_duration(dur_part)
.map_err(|e| format!("intervalle invalide (ex. « 2h », « 30m ») : {e}"))?;
if d < MINUTE {
return Err("intervalle minimum : 1 minute".into());
}
Ok(from + i128::from(d))
}
/// `parseHHMM`.
fn parse_hhmm(s: &str) -> Result<(i64, i64), String> {
let Some((hs, ms)) = s.trim().split_once(':') else {
return Err(format!("heure invalide (attendu HH:MM) : {}", quote(s)));
};
match (go_atoi(hs.trim()), go_atoi(ms.trim())) {
(Some(h), Some(m)) if (0..=23).contains(&h) && (0..=59).contains(&m) => Ok((h, m)),
_ => Err(format!("heure hors bornes : {}", quote(s))),
}
}
/// `strconv.Atoi` (signe accepté).
fn go_atoi(s: &str) -> Option<i64> {
let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// `quote` du paquet time (messages de `ParseDuration`).
fn time_quote(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
if (c as u32) >= 0x80 || c < ' ' {
let mut buf = [0u8; 4];
for byte in c.encode_utf8(&mut buf).bytes() {
out.push_str(&format!("\\x{byte:02x}"));
}
} else {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
}
out.push('"');
out
}
/// `time.ParseDuration` du Go (nanosecondes).
pub fn parse_duration(orig: &str) -> Result<i64, String> {
let invalid = || format!("time: invalid duration {}", time_quote(orig));
let mut s = orig.as_bytes();
let mut neg = false;
if let Some(&c) = s.first()
&& (c == b'-' || c == b'+')
{
neg = c == b'-';
s = &s[1..];
}
if s == b"0" {
return Ok(0);
}
if s.is_empty() {
return Err(invalid());
}
let mut d: u64 = 0;
while !s.is_empty() {
if !(s[0] == b'.' || s[0].is_ascii_digit()) {
return Err(invalid());
}
// [0-9]*
let pl = s.len();
let mut v: u64 = 0;
let mut i = 0;
while i < s.len() && s[i].is_ascii_digit() {
if v > (1u64 << 63) / 10 {
return Err(invalid());
}
v = v * 10 + (s[i] - b'0') as u64;
if v > 1u64 << 63 {
return Err(invalid());
}
i += 1;
}
s = &s[i..];
let pre = pl != s.len();
// (\.[0-9]*)?
let mut post = false;
let (mut f, mut scale) = (0u64, 1f64);
if !s.is_empty() && s[0] == b'.' {
s = &s[1..];
let pl = s.len();
let mut overflow = false;
let mut i = 0;
while i < s.len() && s[i].is_ascii_digit() {
if !overflow {
if f > ((1u64 << 63) - 1) / 10 {
overflow = true;
} else {
let y = f * 10 + (s[i] - b'0') as u64;
if y > 1u64 << 63 {
overflow = true;
} else {
f = y;
scale *= 10.0;
}
}
}
i += 1;
}
s = &s[i..];
post = pl != s.len();
}
if !pre && !post {
return Err(invalid());
}
// L'unité.
let mut i = 0;
while i < s.len() && !(s[i] == b'.' || s[i].is_ascii_digit()) {
i += 1;
}
if i == 0 {
return Err(format!(
"time: missing unit in duration {}",
time_quote(orig)
));
}
let u = String::from_utf8_lossy(&s[..i]).into_owned();
s = &s[i..];
let unit: u64 = match u.as_str() {
"ns" => 1,
"us" | "\u{b5}s" | "\u{3bc}s" => 1_000,
"ms" => 1_000_000,
"s" => NS as u64,
"m" => MINUTE as u64,
"h" => HOUR as u64,
_ => {
return Err(format!(
"time: unknown unit {} in duration {}",
time_quote(&u),
time_quote(orig)
));
}
};
if v > (1u64 << 63) / unit {
return Err(invalid());
}
v *= unit;
if f > 0 {
v += (f as f64 * (unit as f64 / scale)) as u64;
if v > 1u64 << 63 {
return Err(invalid());
}
}
d += v;
if d > 1u64 << 63 {
return Err(invalid());
}
}
if neg {
return Ok((d as i128).wrapping_neg() as i64);
}
if d > (1u64 << 63) - 1 {
return Err(invalid());
}
Ok(d as i64)
}
/// `cronSpec`.
pub struct CronSpec {
min: BTreeSet<i64>,
hour: BTreeSet<i64>,
dom: BTreeSet<i64>,
month: BTreeSet<i64>,
dow: BTreeSet<i64>,
}
impl CronSpec {
fn matches(&self, c: &Civil) -> bool {
self.min.contains(&c.minute)
&& self.hour.contains(&c.hour)
&& self.dom.contains(&c.day)
&& self.month.contains(&c.month)
&& self.dow.contains(&c.weekday)
}
}
/// `nextCron` : le prochain match à la minute, sur ~366 jours au plus.
fn next_cron(expr: &str, tz: &str, from: i128) -> Result<i128, String> {
let spec = parse_cron(expr)?;
let l = loc(tz);
let minute = i128::from(MINUTE);
// Truncate(time.Minute) se fait sur l'instant absolu.
let mut t = from.div_euclid(minute) * minute + minute;
let limit = t + 366 * 24 * i128::from(HOUR);
while t < limit {
if spec.matches(&civil_of(&l, t)) {
return Ok(t);
}
t += minute;
}
Err(format!("fréquence jamais satisfaite : {}", quote(expr)))
}
/// `parseCron`.
pub fn parse_cron(expr: &str) -> Result<CronSpec, String> {
let f: Vec<&str> = expr.split_whitespace().collect();
if f.len() != 5 {
return Err(format!(
"cron : 5 champs attendus « min heure jour mois jour-semaine », reçu {}",
f.len()
));
}
let field = |i: usize, lo, hi, name: &str| {
parse_field(f[i], lo, hi).map_err(|e| format!("champ {name} : {e}"))
};
let min = field(0, 0, 59, "minute")?;
let hour = field(1, 0, 23, "heure")?;
let dom = field(2, 1, 31, "jour-du-mois")?;
let month = field(3, 1, 12, "mois")?;
let mut dow = field(4, 0, 7, "jour-semaine")?;
if dow.contains(&7) {
dow.insert(0);
}
Ok(CronSpec {
min,
hour,
dom,
month,
dow,
})
}
/// `parseField`.
fn parse_field(field: &str, lo: i64, hi: i64) -> Result<BTreeSet<i64>, String> {
let mut out = BTreeSet::new();
for part in field.split(',') {
let mut rng = part;
let mut step = 1;
if let Some((base, st)) = part.split_once('/') {
step = go_atoi(st)
.filter(|n| *n >= 1)
.ok_or_else(|| format!("pas invalide : {}", quote(part)))?;
rng = base;
}
let (mut start, mut end) = (lo, hi);
if rng != "*" {
if let Some((a, b)) = rng.split_once('-') {
start = atoi_range(a, lo, hi)?;
end = atoi_range(b, lo, hi)?;
} else {
let v = atoi_range(rng, lo, hi)?;
start = v;
end = v;
}
}
if start > end {
return Err(format!("plage inversée : {}", quote(part)));
}
let mut v = start;
while v <= end {
out.insert(v);
v += step;
}
}
if out.is_empty() {
return Err("champ vide".into());
}
Ok(out)
}
/// `atoiRange`.
fn atoi_range(s: &str, lo: i64, hi: i64) -> Result<i64, String> {
let v = go_atoi(s.trim()).ok_or_else(|| format!("valeur invalide : {}", quote(s)))?;
if v < lo || v > hi {
return Err(format!("valeur {v} hors bornes [{lo}-{hi}]"));
}
Ok(v)
}
/// `computeNextRun` : le prochain déclenchement en ms Unix, 0 si la fréquence
/// est invalide.
pub fn compute_next_run(schedule: &str, tz: &str, from_ns: i64) -> i64 {
next_after(schedule, tz, from_ns).map_or(0, |t| t.div_euclid(1_000_000) as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durees_du_go() {
assert_eq!(parse_duration("2h"), Ok(2 * HOUR));
assert_eq!(parse_duration("1h30m"), Ok(90 * MINUTE));
assert_eq!(parse_duration("1.5h"), Ok(90 * MINUTE));
assert_eq!(parse_duration(".5m"), Ok(30 * NS));
assert_eq!(parse_duration("0"), Ok(0));
assert_eq!(parse_duration("-2m"), Ok(-2 * MINUTE));
assert_eq!(parse_duration("300ms"), Ok(300_000_000));
assert_eq!(parse_duration("1µs"), Ok(1_000));
assert_eq!(
parse_duration(""),
Err("time: invalid duration \"\"".into())
);
assert_eq!(
parse_duration("2"),
Err("time: missing unit in duration \"2\"".into())
);
assert_eq!(
parse_duration("2d"),
Err("time: unknown unit \"d\" in duration \"2d\"".into())
);
assert_eq!(
parse_duration("abc"),
Err("time: invalid duration \"abc\"".into())
);
assert_eq!(
parse_duration("é"),
Err("time: invalid duration \"\\xc3\\xa9\"".into())
);
assert_eq!(
parse_duration("9999999999h"),
Err("time: invalid duration \"9999999999h\"".into())
);
for big in ["18446744073709551619h", "9223372036854775808ns"] {
assert_eq!(
parse_duration(big),
Err(format!("time: invalid duration \"{big}\"")),
"{big}"
);
}
assert_eq!(parse_duration("9223372036854775807ns"), Ok(i64::MAX));
}
#[test]
fn fuseau_de_la_tache() {
fn paris() -> Location {
Location::load("Europe/Paris").unwrap()
}
// Vide ou introuvable : l'heure locale ; connu : le sien.
assert_eq!(loc_or("", paris).offset_at(1_792_134_000), 7200);
assert_eq!(loc_or("Nulle/Part", paris).offset_at(1_792_134_000), 7200);
assert_eq!(loc_or("UTC", paris).offset_at(1_792_134_000), 0);
assert_eq!(
loc_or("America/New_York", paris).offset_at(1_792_134_000),
-4 * 3600
);
}
/// Valeurs tirées du Go (`nextAfter`).
#[test]
fn bornes_du_calendrier_et_heure_murale() {
let u = Location::utc();
let from = u.date(2026, 3, 28, 10, 0, 0) * NS;
let at = |s: &str, tz: &str, from: i64| next_after(s, tz, from).map(|n| (n / NS128) as i64);
let invalid = Err("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)".to_string());
assert_eq!(at("@once 2100-02-29 10:00", "UTC", from), invalid);
assert_eq!(
at("@once 2400-02-29 10:00", "UTC", from),
Ok(13_574_599_200)
);
assert_eq!(at("@once 2028-02-29 10:00", "UTC", from), Ok(1_835_431_200));
assert_eq!(at("@once 2026-09-31 10:00", "UTC", from), invalid);
assert_eq!(at("@once 2026-11-31 10:00", "UTC", from), invalid);
// À l'heure pile : le lendemain.
let pile = u.date(2026, 3, 28, 23, 0, 0) * NS;
assert_eq!(at("@every 1d@23:00", "UTC", pile), Ok(1_774_825_200));
// Le changement d'heure de la nuit : 9 h murales, pas 24 h plus tard.
let paris = Location::load("Europe/Paris").unwrap();
let matin = paris.date(2026, 3, 28, 10, 0, 0) * NS;
assert_eq!(
at("@every 1d@09:00", "Europe/Paris", matin),
Ok(1_774_767_600)
);
// L'heure qui saute (2 h 30 → 3 h 30) : AddDate repart de 3 h 30.
let apres = paris.date(2026, 3, 29, 4, 0, 0) * NS;
assert_eq!(
at("@every 1d@02:30", "Europe/Paris", apres),
Ok(1_774_834_200)
);
let avant = paris.date(2026, 3, 27, 4, 0, 0) * NS;
assert_eq!(
at("@every 2d@02:30", "Europe/Paris", avant),
Ok(1_774_747_800)
);
// Débordements : la durée du Go, et des dates très lointaines.
assert_eq!(
compute_next_run("@every 200000d", "UTC", from),
607_947_926_290
);
assert_eq!(
compute_next_run("@every 106751d", "UTC", from),
10_997_978_400_000
);
assert_eq!(
compute_next_run("@once 9999-12-31 23:59", "UTC", from),
253_402_300_740_000
);
assert_eq!(
compute_next_run("@every 99999999d@10:00", "UTC", from),
8_641_774_605_600_000
);
// strconv.Atoi accepte un signe.
assert_eq!(at("@every 1d@+9:30", "UTC", from), Ok(1_774_776_600));
assert_eq!(at("@every 1d@-0:30", "UTC", from), Ok(1_774_744_200));
}
#[test]
fn cron_et_erreurs() {
let err = |e: &str| parse_cron(e).err().unwrap_or_default();
assert_eq!(
err("* * *"),
"cron : 5 champs attendus « min heure jour mois jour-semaine », reçu 3"
);
assert_eq!(
err("60 * * * *"),
"champ minute : valeur 60 hors bornes [0-59]"
);
assert_eq!(err("* x * * *"), "champ heure : valeur invalide : \"x\"");
assert_eq!(
err("* * 5-1 * *"),
"champ jour-du-mois : plage inversée : \"5-1\""
);
assert_eq!(err("* * * */0 *"), "champ mois : pas invalide : \"*/0\"");
assert_eq!(
err("* * 5-4 * *"),
"champ jour-du-mois : plage inversée : \"5-4\""
);
assert_eq!(
err("0 9 0 * *"),
"champ jour-du-mois : valeur 0 hors bornes [1-31]"
);
assert_eq!(err("-1 9 * * *"), "champ minute : valeur invalide : \"\"");
assert!(parse_cron("0 9 * * 1-5").is_ok());
let s = parse_cron("0 0 * * 7").unwrap();
assert!(s.dow.contains(&0));
}
#[test]
fn prochains_declenchements() {
let u = Location::utc();
let from = u.date(2026, 10, 15, 21, 10, 30) * NS;
let ns = |t: i64| i128::from(t) * NS128;
let from128 = i128::from(from);
assert_eq!(
next_after("@every 2h", "UTC", from),
Ok(from128 + i128::from(2 * HOUR))
);
assert_eq!(
next_after("@every 30s", "UTC", from),
Err("intervalle minimum : 1 minute".into())
);
assert_eq!(
next_after("@EVERY 1d", "UTC", from),
Ok(from128 + i128::from(24 * HOUR))
);
assert_eq!(
next_after("@every 1d@23:00", "UTC", from),
Ok(ns(u.date(2026, 10, 15, 23, 0, 0)))
);
assert_eq!(
next_after("@every 2d@09:00", "UTC", from),
Ok(ns(u.date(2026, 10, 17, 9, 0, 0)))
);
assert_eq!(
next_after("@every 0d", "UTC", from),
Err("nombre de jours invalide : \"0d\"".into())
);
assert_eq!(
next_after("@every 1d@25:00", "UTC", from),
Err("heure hors bornes : \"25:00\"".into())
);
assert_eq!(
next_after("@every 1d@9", "UTC", from),
Err("heure invalide (attendu HH:MM) : \"9\"".into())
);
assert_eq!(
next_after("0 9 * * 1-5", "UTC", from),
Ok(ns(u.date(2026, 10, 16, 9, 0, 0)))
);
assert_eq!(
next_after("@once 2026-10-16 9:05", "UTC", from),
Ok(ns(u.date(2026, 10, 16, 9, 5, 0)))
);
assert_eq!(
next_after("@once 2026-10-01 16:54", "UTC", from),
Err("2026-10-01 16:54 est déjà passé (il est 2026-10-15 21:10)".into())
);
for bad in [
"@once 2026-02-30 10:00",
"@once 2026-10-16 24:00",
"@once 2026-10-16 23:60",
"@once 2026-13-01 10:00",
"@once 26-10-16 10:00",
"@once 2026-10-16 10:5",
] {
assert_eq!(
next_after(bad, "UTC", from),
Err("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)".into()),
"{bad}"
);
}
assert_eq!(next_after(" ", "UTC", from), Err("fréquence vide".into()));
// Le prochain 1er janvier, à 78 jours ; le 14 octobre, à 364.
assert_eq!(
next_after("0 0 1 1 *", "UTC", from),
Ok(ns(u.date(2027, 1, 1, 0, 0, 0)))
);
assert_eq!(
next_after("0 0 14 10 *", "UTC", from),
Ok(ns(u.date(2027, 10, 14, 0, 0, 0)))
);
assert_eq!(
next_after("0 0 31 2 *", "UTC", from),
Err("fréquence jamais satisfaite : \"0 0 31 2 *\"".into())
);
assert!(is_once(" @Once 2026"));
assert_eq!(
next_after("@once 2026-10-16 7:00", "UTC", from),
Ok(ns(1_792_134_000))
);
assert_eq!(compute_next_run("bad", "UTC", from), 0);
}
}
+822
View File
@@ -0,0 +1,822 @@
//! Tâches planifiées tenues par la façade (lot 26 de la migration,
//! `LOKI_TASKS=rust` ; expérimental, le Go reste le défaut) — port de
//! `tasks.go`, `tasks_sched.go`, `tasks_script.go` et `web_tasks.go`.
//!
//! - les routes `/api/tasks*` (liste, enregistrement, suppression, bascule,
//! interrupteur maître, lancement, arrêt) ;
//! - le planificateur : un tic par minute, une tâche due par tic ;
//! - les tâches « script seul » (un script du dossier protégé, sans modèle).
//!
//! Une tâche IA passe par le verrou de génération et les outils de l'agent,
//! restés au Go : la façade la fait exécuter par lui
//! (`/internal/v1/tasks/run`), qui tient son état comme son propre
//! planificateur.
pub mod cron;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
/// `bkTasks`.
pub const BK_TASKS: &str = "tasks";
/// `reportMax`.
pub const REPORT_MAX: usize = 4000;
/// `scriptTaskTimeout`.
pub const SCRIPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// Attente d'une tâche IA exécutée par le Go (qui ne la borne pas).
const AI_TASK_LIMIT: Duration = Duration::from_secs(24 * 3600);
/// `Task` (mêmes champs JSON).
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Task {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub prompt: String,
#[serde(default)]
pub schedule: String,
#[serde(default)]
pub tz: String,
#[serde(default)]
pub preset: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub script: String,
#[serde(default)]
pub no_mem: bool,
#[serde(default)]
pub no_web: bool,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub last_run: i64,
#[serde(default)]
pub next_run: i64,
#[serde(default)]
pub last_dur_ms: i64,
#[serde(default)]
pub last_ok: bool,
#[serde(default)]
pub last_error: String,
#[serde(default)]
pub last_report: String,
}
/// Maintenant, en ns Unix.
pub fn now_ns() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64)
}
/// `newTaskID` : 6 octets aléatoires en hexadécimal.
pub fn new_task_id() -> String {
let mut b = [0u8; 6];
let _ = getrandom::fill(&mut b);
hex::encode(b)
}
/// Les tâches tenues ici : la base, et l'exécution en cours.
pub struct Tasks {
pub home: PathBuf,
/// La tâche IA que le Go exécute pour la façade (son id).
running: Mutex<Option<String>>,
/// Les tâches script en cours (id → signal d'arrêt) — `scriptRunning`.
scripts: Mutex<BTreeMap<String, tokio::sync::watch::Sender<bool>>>,
/// Pas du planificateur (une minute).
pub tick: Duration,
/// L'heure (ns Unix) — remplaçable en test.
pub clock: fn() -> i64,
/// Un tic tout de suite (tests).
kick: tokio::sync::Notify,
/// Tics achevés (le premier passage compte pour un).
ticks: AtomicU64,
/// Joignabilité de Crawl4AI (cache de 30 s, comme le Go).
reach: crate::system::System,
}
impl Tasks {
pub fn new(home: PathBuf) -> Self {
Self::with(home, Duration::from_secs(60), now_ns)
}
/// Avec le pas du planificateur et l'horloge donnés.
pub fn with(home: PathBuf, tick: Duration, clock: fn() -> i64) -> Self {
Tasks {
home,
running: Mutex::new(None),
scripts: Mutex::new(BTreeMap::new()),
tick,
clock,
kick: tokio::sync::Notify::new(),
ticks: AtomicU64::new(0),
reach: crate::system::System::new(),
}
}
pub fn now(&self) -> i64 {
(self.clock)()
}
/// La tâche en cours vue par l'écran : l'IA d'abord, sinon un script.
pub fn running_id(&self) -> String {
if let Some(id) = self
.running
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
return id;
}
self.scripts
.lock()
.unwrap_or_else(|e| e.into_inner())
.keys()
.next()
.cloned()
.unwrap_or_default()
}
/// Une tâche script tourne-t-elle encore ?
pub fn script_running(&self) -> bool {
!self
.scripts
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
fn set_running(&self, r: Option<String>) {
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = r;
}
/// Demande un tic tout de suite.
pub fn kick(&self) {
self.kick.notify_one();
}
/// Le nombre de tics achevés.
pub fn ticks(&self) -> u64 {
self.ticks.load(Ordering::SeqCst)
}
/// `scriptsDir`.
pub fn scripts_dir(&self) -> PathBuf {
self.home.join("scripts")
}
}
/// `listTasks` : triées par nom, puis par id.
pub async fn list_tasks(store: &Store) -> Vec<Task> {
let mut out: Vec<Task> = store
.all(BK_TASKS)
.await
.unwrap_or_default()
.into_values()
.filter_map(|v| serde_json::from_str(&v).ok())
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
out
}
/// `getTask`.
pub async fn get_task(store: &Store, id: &str) -> Option<Task> {
let b = store.get(BK_TASKS, id).await.ok().flatten()?;
if b.is_empty() {
return None;
}
serde_json::from_slice(&b).ok()
}
/// `saveTask`.
pub async fn save_task(store: &Store, t: &Task) -> Result<(), String> {
store
.put(BK_TASKS, &t.id, gojson::marshal(t).into_bytes())
.await
}
/// `tasksPaused`.
pub async fn tasks_paused(store: &Store) -> bool {
store.get_str(BK_STATE, "tasks_paused").await == "1"
}
/// `scriptsPath` : un script DANS le dossier protégé — ni chemin absolu, ni
/// remontée, ni caractère hostile au shell.
pub fn scripts_path(dir: &Path, name: &str) -> Result<PathBuf, String> {
let name = name.trim();
if name.is_empty() {
return Err("nom de script vide".into());
}
if name.starts_with('/') || name.starts_with('\\') {
return Err(format!(
"nom de script invalide (commence par un séparateur) : {name}"
));
}
if let Some(i) = name.find([
'"', '\'', '`', '$', ';', '&', '|', '<', '>', '*', '?', '\\', '\n', '\r',
]) {
return Err(format!(
"nom de script invalide (caractère interdit {}) : {name}",
gojson::quote(&name[i..i + 1])
));
}
let clean = crate::paths::go_clean(name);
if clean.starts_with('/') || clean == ".." || clean.starts_with("../") {
return Err(format!(
"nom de script invalide (pas de chemin absolu ni de ../) : {name}"
));
}
Ok(dir.join(clean))
}
/// `scriptExists`.
pub fn script_exists(dir: &Path, name: &str) -> Result<(), String> {
let full = scripts_path(dir, name)?;
match std::fs::metadata(&full) {
Err(_) => Err(format!("script introuvable : {name}")),
Ok(m) if m.is_dir() => Err(format!("{name} est un dossier, pas un script")),
Ok(_) => Ok(()),
}
}
/// `listScripts` : les scripts du dossier (chemins relatifs, triés comme
/// `filepath.WalkDir`).
pub fn list_scripts(dir: &Path) -> Vec<Value> {
fn walk(root: &Path, p: &Path, out: &mut Vec<Value>) {
let Ok(rd) = std::fs::read_dir(p) else {
return;
};
let mut entries: Vec<_> = rd.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let path = e.path();
let Ok(ft) = e.file_type() else { continue };
if ft.is_dir() {
walk(root, &path, out);
continue;
}
let rel = path
.strip_prefix(root)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let size = e.metadata().map_or(0, |m| m.len());
out.push(json!({ "name": rel, "size": size }));
}
}
let mut out = Vec::new();
walk(dir, dir, &mut out);
out.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
out
}
// ---------------------------------------------------------------------------
// Exécution
// ---------------------------------------------------------------------------
/// `ErrBusy`.
pub const ERR_BUSY: &str = "génération en cours";
/// `errModelLoading`.
pub const ERR_MODEL_LOADING: &str =
"⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.";
/// Fin d'une exécution.
pub enum Outcome {
Ok(String),
Err(String),
Canceled,
}
/// `recordTaskEnd` : durée, dernier et prochain passage, succès ou échec,
/// compte-rendu — sur la tâche relue (une édition concurrente n'est pas
/// écrasée) ; puis la notification.
pub async fn record_task_end(ctx: &Ctx, tasks: &Tasks, id: &str, start_ns: i64, out: Outcome) {
let store = &ctx.store;
let now = tasks.now();
let Some(mut cur) = get_task(store, id).await else {
return;
};
cur.last_dur_ms = (now - start_ns).div_euclid(1_000_000);
cur.last_run = now.div_euclid(1_000_000);
cur.next_run = cron::compute_next_run(&cur.schedule, &cur.tz, now);
if cron::is_once(&cur.schedule) {
cur.enabled = false;
cur.next_run = 0;
}
let (failed, canceled) = match &out {
Outcome::Canceled => {
cur.last_ok = true;
cur.last_error.clear();
cur.last_report = "(interrompue manuellement)".into();
(false, true)
}
Outcome::Err(e) => {
cur.last_ok = false;
cur.last_error = e.clone();
(true, false)
}
Outcome::Ok(report) => {
cur.last_ok = true;
cur.last_error.clear();
cur.last_report = truncate_report(report);
(false, false)
}
};
let _ = save_task(store, &cur).await;
if !canceled {
let title = if failed {
"Loki · tâche en échec"
} else {
"Loki · tâche terminée"
};
push(ctx, title, &cur.name).await;
}
}
/// Le compte-rendu borné à `reportMax` octets : `report[:n] + "…"` du Go,
/// qui peut couper un caractère — ses octets orphelins deviennent chacun
/// U+FFFD, comme à l'encodage JSON du Go.
fn truncate_report(r: &str) -> String {
if r.len() <= REPORT_MAX {
return r.to_string();
}
format!("{}…", crate::system::go_lossy(&r.as_bytes()[..REPORT_MAX]))
}
/// La notification de fin : par la façade si elle tient les notifications,
/// sinon par le Go.
async fn push(ctx: &Ctx, title: &str, body: &str) {
if let Some(p) = ctx.push.clone() {
let (store, t, b) = (ctx.store.clone(), title.to_string(), body.to_string());
tokio::spawn(async move { p.send_all(&store, &t, &b).await });
return;
}
let _ = ctx
.store
.internal_post(
"/internal/v1/push/send",
&json!({ "title": title, "body": body }),
)
.await;
}
/// `runTask` : une tâche script ici, une tâche IA par le Go.
pub async fn run_task(ctx: &Ctx, tasks: &Tasks, t: Task) {
if t.kind == "script" {
return run_script_task(ctx, tasks, t).await;
}
tasks.set_running(Some(t.id.clone()));
// Le Go rend la main à la fin du passage (une tâche IA dure).
let _ = ctx
.store
.internal_post_within(
"/internal/v1/tasks/run",
&json!({ "id": t.id }),
AI_TASK_LIMIT,
)
.await;
tasks.set_running(None);
}
/// `runScriptTask` : le script, sans modèle ; sa sortie fait le compte-rendu.
async fn run_script_task(ctx: &Ctx, tasks: &Tasks, t: Task) {
let start = tasks.now();
let name = t.script.trim();
if name.is_empty() {
let e = "aucun script associé à la tâche".to_string();
return record_task_end(ctx, tasks, &t.id, start, Outcome::Err(e)).await;
}
let full = match scripts_path(&tasks.scripts_dir(), name) {
Ok(p) => p,
Err(e) => return record_task_end(ctx, tasks, &t.id, start, Outcome::Err(e)).await,
};
if std::fs::metadata(&full).is_err() {
let e = format!("script introuvable : {name}");
return record_task_end(ctx, tasks, &t.id, start, Outcome::Err(e)).await;
}
let (stop_tx, mut stop_rx) = tokio::sync::watch::channel(false);
tasks
.scripts
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(t.id.clone(), stop_tx);
let (cwd, mem) = shell_dirs(ctx).await;
let cmd = script_run_command(&full);
let run = crate::tools::shell::run_shell(&cwd, &mem, &cmd, SCRIPT_TIMEOUT.as_secs() as i64);
let out = tokio::select! {
out = run => Some(out),
_ = stop_rx.wait_for(|s| *s) => None,
};
let outcome = match out {
None => Outcome::Canceled,
Some(o) if o.starts_with("exit: 0") => Outcome::Ok(o),
Some(o) => Outcome::Err(o),
};
record_task_end(ctx, tasks, &t.id, start, outcome).await;
tasks
.scripts
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&t.id);
}
/// `scriptRunCommand` : le script lancé directement (son shebang choisit
/// l'interpréteur) ; sous Windows, un `.ps1` passe par PowerShell. Le nom est
/// déjà bridé par `scripts_path` : les guillemets suffisent.
fn script_run_command(path: &Path) -> String {
let q = format!("\"{}\"", path.to_string_lossy());
let ps1 = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("ps1"));
if cfg!(windows) && ps1 {
return format!("powershell -NoProfile -ExecutionPolicy Bypass -File {q}");
}
q
}
/// Le dossier de travail de la discussion ouverte (là où `runShell` lance
/// ses commandes) et le dossier mémoire du projet actif (gardé du shell).
async fn shell_dirs(ctx: &Ctx) -> (PathBuf, PathBuf) {
let v = ctx
.store
.internal_json(
hyper::Method::POST,
"/internal/v1/conv/workspace",
Some(&json!({})),
)
.await
.unwrap_or(Value::Null);
let cwd = v
.get("dir")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let active = crate::memory::active_project(&ctx.store).await;
let mem = crate::paths::project_memory_dir(&ctx.engine.home, &active);
(PathBuf::from(cwd), PathBuf::from(mem))
}
// ---------------------------------------------------------------------------
// Planificateur
// ---------------------------------------------------------------------------
/// `primeNextRuns` : le prochain passage des tâches actives qui n'en ont pas.
pub async fn prime_next_runs(ctx: &Ctx, tasks: &Tasks) {
let now = tasks.now();
for mut t in list_tasks(&ctx.store).await {
if t.enabled && t.next_run == 0 {
t.next_run = cron::compute_next_run(&t.schedule, &t.tz, now);
let _ = save_task(&ctx.store, &t).await;
}
}
}
/// `tickTasks` : la première tâche due (une par tic), sauf interrupteur
/// maître ou optimisation en cours.
pub async fn tick_tasks(ctx: &Ctx, tasks: &Tasks) {
if tasks_paused(&ctx.store).await || crate::backends::tune_busy(&ctx.store).await.is_some() {
return;
}
let now = tasks.now();
let now_ms = now.div_euclid(1_000_000);
for mut t in list_tasks(&ctx.store).await {
if !t.enabled {
continue;
}
if t.next_run == 0 {
t.next_run = cron::compute_next_run(&t.schedule, &t.tz, now);
let _ = save_task(&ctx.store, &t).await;
continue;
}
if t.next_run <= now_ms {
run_task(ctx, tasks, t).await;
return;
}
}
}
/// La boucle : un premier passage tout de suite, puis un tic par minute (ou
/// sur demande).
pub async fn sched_loop(ctx: Ctx, tasks: Arc<Tasks>) {
prime_next_runs(&ctx, &tasks).await;
tasks.ticks.fetch_add(1, Ordering::SeqCst);
let mut iv = tokio::time::interval(tasks.tick);
iv.tick().await;
loop {
tokio::select! {
_ = iv.tick() => {}
_ = tasks.kick.notified() => {}
}
tick_tasks(&ctx, &tasks).await;
tasks.ticks.fetch_add(1, Ordering::SeqCst);
}
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
const SAVE_REQ: &str = r#"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\"" }"#;
const ID_REQ: &str = r#"struct { ID string "json:\"id\"" }"#;
const TOGGLE_REQ: &str = r#"struct { ID string "json:\"id\""; On bool "json:\"on\"" }"#;
const ON_REQ: &str = r#"struct { On bool "json:\"on\"" }"#;
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/tasks"
| "/api/tasks/save"
| "/api/tasks/delete"
| "/api/tasks/toggle"
| "/api/tasks/pause"
| "/api/tasks/run"
| "/api/tasks/stop"
)
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// `handleTasks`.
async fn overview(ctx: &Ctx, tasks: &Tasks) -> Value {
let store = &ctx.store;
let list = list_tasks(store).await;
let running_id = tasks.running_id();
let cfg = store.config().await;
let order = crate::presets::parse_preset_order(
store
.get(BK_STATE, crate::presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let presets: Vec<Value> = crate::presets::list_presets(&tasks.home, &cfg, &order)
.map(|l| {
l.into_iter()
.map(|p| json!({ "id": p.id, "name": p.name, "active": p.active }))
.collect()
})
.unwrap_or_default();
let switch_on = store.get_str(BK_STATE, "internet").await == "1";
let key = store.get_str(BK_STATE, "crawl_key").await;
let web_on =
crate::system::internet_enabled(&cfg, switch_on) && tasks.reach.reachable(&cfg, &key).await;
json!({
"ok": true,
"tasks": if list.is_empty() { Value::Null } else { serde_json::to_value(&list).unwrap_or(Value::Null) },
"scripts": list_scripts(&tasks.scripts_dir()),
"scripts_dir": tasks.scripts_dir().to_string_lossy(),
"paused": tasks_paused(store).await,
"agent": store.get_str(BK_STATE, "agent").await == "1",
"running_id": running_id,
"presets": presets,
"projects": crate::projects_api::project_dtos(ctx).await,
"mem_on": crate::memory::Memory::mem_mode(store).await != "off",
"web_on": web_on,
})
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
tasks: &Arc<Tasks>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let raw = Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default();
let store = &ctx.store;
match parts.uri.path() {
"/api/tasks" => send_json(StatusCode::OK, &overview(ctx, tasks).await),
"/api/tasks/save" => {
let d = gojson::decode_struct(
&raw,
SAVE_REQ,
&[
("id", Kind::Str),
("name", Kind::Str),
("prompt", Kind::Str),
("schedule", Kind::Str),
("tz", Kind::Str),
("preset", Kind::Str),
("project", Kind::Str),
("kind", Kind::Str),
("script", Kind::Str),
("no_mem", Kind::Bool),
("no_web", Kind::Bool),
("enabled", Kind::Bool),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let name = d.str("name").trim().to_string();
let prompt = d.str("prompt").trim().to_string();
let schedule = d.str("schedule").trim().to_string();
let kind = d.str("kind").trim().to_string();
let script = d.str("script").trim().to_string();
let tz = d.str("tz");
if name.is_empty() {
return fail(StatusCode::BAD_REQUEST, "nom obligatoire");
}
if kind == "script" {
if script.is_empty() {
return fail(
StatusCode::BAD_REQUEST,
"script obligatoire pour une tâche script",
);
}
if let Err(e) = script_exists(&tasks.scripts_dir(), &script) {
return fail(StatusCode::BAD_REQUEST, e);
}
} else if prompt.is_empty() {
return fail(StatusCode::BAD_REQUEST, "consigne obligatoire");
}
let now = tasks.now();
if let Err(e) = cron::next_after(&schedule, &tz, now) {
return fail(StatusCode::BAD_REQUEST, format!("fréquence invalide : {e}"));
}
let id = d.str("id");
let mut t = Task::default();
if !id.is_empty() {
match get_task(store, &id).await {
Some(cur) => t = cur,
None => t.id = id,
}
}
if t.id.is_empty() {
t.id = new_task_id();
}
t.name = name;
t.prompt = prompt;
t.enabled = d.bool("enabled");
t.tz = tz.clone();
t.preset = d.str("preset");
t.kind = kind;
t.script = script;
let project = d.str("project").trim().to_string();
let projects = crate::memory::list_projects(store).await;
if !project.is_empty() && projects.iter().any(|p| p.slug == project) {
t.project = project;
} else if t.project.is_empty() {
t.project = crate::memory::active_project(store).await;
}
t.no_mem = d.bool("no_mem");
t.no_web = d.bool("no_web");
if t.schedule != schedule || t.next_run == 0 {
t.next_run = cron::compute_next_run(&schedule, &tz, now);
}
t.schedule = schedule;
if let Err(e) = save_task(store, &t).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(StatusCode::OK, &json!({ "ok": true, "id": t.id }))
}
"/api/tasks/delete" => {
let d = gojson::decode_struct(&raw, ID_REQ, &[("id", Kind::Str)]);
let id = d.str("id");
if id.is_empty() {
return fail(StatusCode::BAD_REQUEST, "id manquant");
}
if let Err(e) = store.del(BK_TASKS, &id).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/tasks/toggle" => {
let d =
gojson::decode_struct(&raw, TOGGLE_REQ, &[("id", Kind::Str), ("on", Kind::Bool)]);
let Some(mut t) = get_task(store, &d.str("id")).await else {
return fail(StatusCode::NOT_FOUND, "tâche introuvable");
};
t.enabled = d.bool("on");
if t.enabled {
t.next_run = cron::compute_next_run(&t.schedule, &t.tz, tasks.now());
}
if let Err(e) = save_task(store, &t).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/tasks/pause" => {
let d = gojson::decode_struct(&raw, ON_REQ, &[("on", Kind::Bool)]);
let res = if d.bool("on") {
store.put(BK_STATE, "tasks_paused", b"1".to_vec()).await
} else {
store.del(BK_STATE, "tasks_paused").await
};
if let Err(e) = res {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "paused": tasks_paused(store).await }),
)
}
"/api/tasks/stop" => {
let d = gojson::decode_struct(&raw, ID_REQ, &[("id", Kind::Str)]);
let id = d.str("id");
let stop = (!id.is_empty())
.then(|| {
tasks
.scripts
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&id)
.cloned()
})
.flatten();
if let Some(s) = stop {
let _ = s.send(true);
return send_json(StatusCode::OK, &json!({ "ok": true }));
}
let _ = store
.internal_post("/internal/v1/engine/stop", &json!({}))
.await;
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/tasks/run" => {
let d = gojson::decode_struct(&raw, ID_REQ, &[("id", Kind::Str)]);
let Some(t) = get_task(store, &d.str("id")).await else {
return fail(StatusCode::NOT_FOUND, "tâche introuvable");
};
if t.kind != "script" {
if crate::enginectl::generating(ctx).await {
return fail(StatusCode::CONFLICT, ERR_BUSY);
}
if !crate::engstatus::health_check(&store.config().await).await {
return fail(StatusCode::SERVICE_UNAVAILABLE, ERR_MODEL_LOADING);
}
}
let (ctx2, tasks2) = (ctx.clone(), tasks.clone());
tokio::spawn(async move { run_task(&ctx2, &tasks2, t).await });
send_json(StatusCode::OK, &json!({ "ok": true }))
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compte_rendu_borne() {
let pile = "x".repeat(REPORT_MAX);
assert_eq!(truncate_report(&pile), pile);
let long = format!("a{}", "é".repeat(2000));
let want = format!("a{}\u{FFFD}…", "é".repeat(1999));
assert_eq!(truncate_report(&long), want);
// Coupé entre deux caractères : rien à remplacer.
let net = format!("{}é", "x".repeat(REPORT_MAX));
assert_eq!(
truncate_report(&net),
format!("{}…", "x".repeat(REPORT_MAX))
);
}
#[test]
fn noms_de_script() {
let dir = Path::new("/srv/scripts");
assert_eq!(scripts_path(dir, " sous/a.sh "), Ok(dir.join("sous/a.sh")));
assert_eq!(scripts_path(dir, "./a.sh"), Ok(dir.join("a.sh")));
assert_eq!(scripts_path(dir, " "), Err("nom de script vide".into()));
assert_eq!(
scripts_path(dir, "a/../../b"),
Err("nom de script invalide (pas de chemin absolu ni de ../) : a/../../b".into())
);
assert_eq!(
scripts_path(dir, "a\nb"),
Err("nom de script invalide (caractère interdit \"\\n\") : a\nb".into())
);
}
}
+756
View File
@@ -0,0 +1,756 @@
//! Fuseaux horaires à la Go (`time.LoadLocation`, `time.Local`) : les fichiers
//! TZif du système (`/usr/share/zoneinfo`…), lus comme le fait la bibliothèque
//! standard du Go — transitions 64 bits (v2+), règle POSIX du pied de fichier
//! pour les dates au-delà de la dernière transition — et la conversion d'une
//! heure murale en instant comme `time.Date`.
//!
//! Sert aux tâches planifiées (fréquences cron, « tous les jours à 23:00 »,
//! « @once ») : la même zoneinfo que le Go, donc les mêmes déclenchements.
use std::path::Path;
/// Une zone : décalage UTC (s), heure d'été, abréviation.
#[derive(Clone, Debug, PartialEq)]
pub struct Zone {
pub name: String,
pub offset: i64,
pub is_dst: bool,
}
/// Un fuseau chargé.
#[derive(Clone, Debug)]
pub struct Location {
pub name: String,
zones: Vec<Zone>,
/// (instant, index de zone), triés.
tx: Vec<(i64, usize)>,
/// Règle POSIX au-delà de la dernière transition.
extend: String,
}
const ALPHA: i64 = i64::MIN;
const OMEGA: i64 = i64::MAX;
impl Location {
/// `time.UTC`.
pub fn utc() -> Self {
Location {
name: "UTC".into(),
zones: vec![Zone {
name: "UTC".into(),
offset: 0,
is_dst: false,
}],
tx: Vec::new(),
extend: String::new(),
}
}
/// `time.LoadLocation` : "" ou "UTC" = UTC ; sinon le fichier TZif du
/// système. Erreur si introuvable ou illisible.
pub fn load(name: &str) -> Result<Self, String> {
if name.is_empty() || name == "UTC" {
return Ok(Self::utc());
}
if name == "Local" {
return Ok(Self::local());
}
let mut sources: Vec<String> = Vec::new();
if let Ok(z) = std::env::var("ZONEINFO")
&& !z.is_empty()
{
sources.push(z);
}
sources.extend(
[
"/usr/share/zoneinfo/",
"/usr/share/lib/zoneinfo/",
"/usr/lib/locale/TZ/",
"/etc/zoneinfo/",
]
.iter()
.map(|s| s.to_string()),
);
Self::load_in(name, &sources)
}
/// `LoadLocation` sur les bases données (dans l'ordre).
fn load_in(name: &str, sources: &[String]) -> Result<Self, String> {
if name.is_empty() || name == "UTC" {
return Ok(Self::utc());
}
if !valid_name(name) {
return Err("time: invalid location name".to_string());
}
for src in sources {
let p = Path::new(src).join(name);
if let Ok(b) = std::fs::read(&p) {
return parse_tzif(name, &b);
}
}
Err(format!("unknown time zone {name}"))
}
/// `time.Local` : `$TZ` (vide = UTC, « :chemin » accepté), sinon
/// /etc/localtime, sinon UTC.
pub fn local() -> Self {
Self::local_from(std::env::var("TZ").ok(), Path::new("/etc/localtime"))
}
/// `initLocal` : `tz` la valeur de `$TZ` (absente : `None`), `etc` le
/// fuseau du système.
fn local_from(tz: Option<String>, etc: &Path) -> Self {
match tz {
Some(tz) => {
let tz = tz.strip_prefix(':').unwrap_or(&tz).to_string();
if tz.is_empty() || tz == "UTC" {
return Self::utc();
}
if tz.starts_with('/') {
if let Ok(b) = std::fs::read(&tz)
&& let Ok(mut l) = parse_tzif("Local", &b)
{
l.name = "Local".into();
return l;
}
return Self::utc();
}
match Self::load(&tz) {
Ok(mut l) => {
l.name = "Local".into();
l
}
Err(_) => Self::utc(),
}
}
None => match std::fs::read(etc)
.ok()
.and_then(|b| parse_tzif("Local", &b).ok())
{
Some(mut l) => {
l.name = "Local".into();
l
}
None => {
let mut u = Self::utc();
u.name = "Local".into();
u
}
},
}
}
/// `(*Location).lookup` : la zone d'un instant et la période où elle
/// vaut, [start, end[.
pub fn lookup(&self, sec: i64) -> (Zone, i64, i64) {
if self.zones.is_empty() {
return (
Zone {
name: "UTC".into(),
offset: 0,
is_dst: false,
},
ALPHA,
OMEGA,
);
}
if self.tx.is_empty() || sec < self.tx[0].0 {
let z = self.zones[self.lookup_first_zone()].clone();
let end = self.tx.first().map_or(OMEGA, |t| t.0);
return (z, ALPHA, end);
}
// Dichotomie : la dernière transition <= sec.
let (mut lo, mut hi) = (0usize, self.tx.len());
let mut end = OMEGA;
while hi - lo > 1 {
let m = lo + (hi - lo) / 2;
let lim = self.tx[m].0;
if sec < lim {
end = lim;
hi = m;
} else {
lo = m;
}
}
let zone = self.zones[self.tx[lo].1].clone();
let start = self.tx[lo].0;
if lo == self.tx.len() - 1
&& !self.extend.is_empty()
&& let Some((z, s, e)) = tzset(&self.extend, start, sec)
{
return (z, s, e);
}
(zone, start, end)
}
/// `lookupFirstZone`.
fn lookup_first_zone(&self) -> usize {
if !self.first_zone_used() {
return 0;
}
if !self.tx.is_empty() && self.zones[self.tx[0].1].is_dst {
let mut zi = self.tx[0].1 as i64 - 1;
while zi >= 0 {
if !self.zones[zi as usize].is_dst {
return zi as usize;
}
zi -= 1;
}
}
for (i, z) in self.zones.iter().enumerate() {
if !z.is_dst {
return i;
}
}
0
}
fn first_zone_used(&self) -> bool {
self.tx.iter().any(|t| t.1 == 0)
}
/// Le décalage d'un instant.
pub fn offset_at(&self, sec: i64) -> i64 {
self.lookup(sec).0.offset
}
/// `time.Date(y, m, d, h, mi, s, 0, loc)` : l'instant (s Unix) d'une heure
/// murale, normalisée comme le Go (débordements reportés).
pub fn date(&self, y: i64, m: i64, d: i64, h: i64, mi: i64, s: i64) -> i64 {
// Normalisation du mois puis des secondes du jour.
let (mut y, mut m) = (y, m - 1);
y += m.div_euclid(12);
m = m.rem_euclid(12);
let days = days_from_civil(y, m + 1, 1) + (d - 1);
let unix = days * 86400 + h * 3600 + mi * 60 + s;
let (z, start, end) = self.lookup(unix);
let mut offset = z.offset;
let mut out = unix;
if offset != 0 {
let utc = unix - offset;
if utc < start || utc >= end {
offset = self.lookup(utc).0.offset;
}
out -= offset;
}
out
}
/// L'heure murale d'un instant : (année, mois, jour, heure, minute,
/// seconde, jour de la semaine 0 = dimanche).
pub fn civil(&self, sec: i64) -> Civil {
civil(sec + self.offset_at(sec))
}
}
/// Une heure murale.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Civil {
pub year: i64,
pub month: i64,
pub day: i64,
pub hour: i64,
pub minute: i64,
pub second: i64,
pub weekday: i64,
}
impl Civil {
/// `Format("2006-01-02 15:04")`.
pub fn ymd_hm(&self) -> String {
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
self.year, self.month, self.day, self.hour, self.minute
)
}
}
/// Les secondes « locales » (UTC + décalage) en heure murale.
pub fn civil(local: i64) -> Civil {
let days = local.div_euclid(86400);
let rem = local.rem_euclid(86400);
let (y, m, d) = civil_from_days(days);
Civil {
year: y,
month: m,
day: d,
hour: rem / 3600,
minute: rem % 3600 / 60,
second: rem % 60,
weekday: (days + 4).rem_euclid(7),
}
}
/// Jours depuis 1970-01-01 (calendrier grégorien proleptique).
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
/// L'inverse de [`days_from_civil`].
pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
fn is_leap(y: i64) -> bool {
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
}
/// `containsDotDot` et consorts : un nom de zone sans « .. » ni chemin
/// absolu.
/// Ni `..` (n'importe où, comme `containsDotDot`), ni chemin absolu.
fn valid_name(name: &str) -> bool {
!(name.contains("..") || name.starts_with('/') || name.starts_with('\\'))
}
fn be32(b: &[u8]) -> i64 {
i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as i64
}
fn be64(b: &[u8]) -> i64 {
i64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
}
/// `LoadLocationFromTZData`.
pub fn parse_tzif(name: &str, data: &[u8]) -> Result<Location, String> {
let bad = || "malformed time zone information".to_string();
if data.len() < 44 || &data[..4] != b"TZif" {
return Err(bad());
}
let version = data[4];
let counts = |off: usize| -> [usize; 6] {
let mut n = [0usize; 6];
for (i, v) in n.iter_mut().enumerate() {
*v = u32::from_be_bytes([
data[off + 20 + i * 4],
data[off + 21 + i * 4],
data[off + 22 + i * 4],
data[off + 23 + i * 4],
]) as usize;
}
n
};
// [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt]
let mut n = counts(0);
let mut off = 44;
let mut wide = false;
if version >= b'2' {
// Saute le bloc v1.
let skip = n[3] * 4 + n[3] + n[4] * 6 + n[5] + n[2] * 8 + n[1] + n[0];
let o2 = 44 + skip;
if data.len() < o2 + 44 || &data[o2..o2 + 4] != b"TZif" {
return Err(bad());
}
n = counts(o2);
off = o2 + 44;
wide = true;
}
let tsz = if wide { 8 } else { 4 };
let need = n[3] * tsz + n[3] + n[4] * 6 + n[5] + n[2] * (tsz + 4) + n[1] + n[0];
if data.len() < off + need {
return Err(bad());
}
let times = &data[off..off + n[3] * tsz];
let idx = &data[off + n[3] * tsz..off + n[3] * tsz + n[3]];
let zo = off + n[3] * tsz + n[3];
let zdata = &data[zo..zo + n[4] * 6];
let chars = &data[zo + n[4] * 6..zo + n[4] * 6 + n[5]];
let after = zo + n[4] * 6 + n[5] + n[2] * (tsz + 4) + n[1] + n[0];
let mut extend = String::new();
if wide && data.len() > after && data[after] == b'\n' {
let rest = &data[after + 1..];
if let Some(e) = rest.iter().position(|c| *c == b'\n') {
extend = String::from_utf8_lossy(&rest[..e]).into_owned();
}
}
if n[4] == 0 {
return Err(bad());
}
let mut zones = Vec::new();
for i in 0..n[4] {
let z = &zdata[i * 6..i * 6 + 6];
let off = be32(z);
let is_dst = z[4] != 0;
let di = z[5] as usize;
if di >= chars.len() {
return Err(bad());
}
let end = chars[di..]
.iter()
.position(|c| *c == 0)
.unwrap_or(chars.len() - di);
zones.push(Zone {
name: String::from_utf8_lossy(&chars[di..di + end]).into_owned(),
offset: off,
is_dst,
});
}
let mut tx = Vec::new();
for i in 0..n[3] {
let t = if wide {
be64(&times[i * 8..])
} else {
be32(&times[i * 4..])
};
let zi = idx[i] as usize;
if zi >= zones.len() {
return Err(bad());
}
tx.push((t, zi));
}
if tx.is_empty() {
// Comme le Go : une transition fictive au début des temps.
tx.push((ALPHA, 0));
}
Ok(Location {
name: name.to_string(),
zones,
tx,
extend,
})
}
// ---------------------------------------------------------------------------
// Règle POSIX (tzset du Go)
// ---------------------------------------------------------------------------
/// `tzset` : la zone d'un instant d'après une règle POSIX (« CET-1CEST,M3.5.0,
/// M10.5.0/3 »), et sa période. `None` : règle illisible.
fn tzset(s: &str, last_tx: i64, sec: i64) -> Option<(Zone, i64, i64)> {
let (std_name, s) = tzset_name(s)?;
let (std_off, s) = tzset_offset(s)?;
let std_offset = -std_off;
if s.is_empty() || s.starts_with(',') {
// Pas d'heure d'été.
return Some((
Zone {
name: std_name,
offset: std_offset,
is_dst: false,
},
last_tx,
OMEGA,
));
}
let (dst_name, s) = tzset_name(s)?;
let (dst_offset, s) = if s.is_empty() || s.starts_with(',') {
(std_offset + 3600, s)
} else {
let (o, r) = tzset_offset(s)?;
(-o, r)
};
let s = if s.is_empty() { ",M3.2.0,M11.1.0" } else { s };
let s = s.strip_prefix(',')?;
let (start_rule, s) = tzset_rule(s)?;
let s = s.strip_prefix(',')?;
let (end_rule, s) = tzset_rule(s)?;
if !s.is_empty() {
return None;
}
let (year, yday) = {
let c = civil(sec);
let doy = days_from_civil(c.year, c.month, c.day) - days_from_civil(c.year, 1, 1);
(c.year, doy)
};
let _ = yday;
let ysec = days_from_civil(year, 1, 1) * 86400;
let mut start = ysec + tzrule_time(year, &start_rule, std_offset);
let mut end = ysec + tzrule_time(year, &end_rule, dst_offset);
let (dst_is_dst, std_is_dst) = (true, false);
let (mut lo, mut hi) = (
Zone {
name: std_name.clone(),
offset: std_offset,
is_dst: std_is_dst,
},
Zone {
name: dst_name.clone(),
offset: dst_offset,
is_dst: dst_is_dst,
},
);
if end < start {
std::mem::swap(&mut start, &mut end);
std::mem::swap(&mut lo, &mut hi);
}
let yearend = days_from_civil(year + 1, 1, 1) * 86400;
if sec < start {
return Some((lo, ysec, start));
} else if sec >= end {
return Some((lo, end, yearend));
}
Some((hi, start, end))
}
fn tzset_name(s: &str) -> Option<(String, &str)> {
if s.is_empty() {
return None;
}
if let Some(r) = s.strip_prefix('<') {
let e = r.find('>')?;
return Some((r[..e].to_string(), &r[e + 1..]));
}
let e = s
.find(|c: char| c.is_ascii_digit() || c == ',' || c == '-' || c == '+')
.unwrap_or(s.len());
if e < 3 {
return None;
}
Some((s[..e].to_string(), &s[e..]))
}
fn tzset_offset(s: &str) -> Option<(i64, &str)> {
let (neg, s) = match s.as_bytes().first()? {
b'+' => (false, &s[1..]),
b'-' => (true, &s[1..]),
_ => (false, s),
};
let (h, mut s) = tzset_num(s, 0, 24 * 7)?;
let mut off = h * 3600;
if let Some(r) = s.strip_prefix(':') {
let (m, r) = tzset_num(r, 0, 59)?;
off += m * 60;
s = r;
if let Some(r) = s.strip_prefix(':') {
let (sec, r) = tzset_num(r, 0, 59)?;
off += sec;
s = r;
}
}
Some((if neg { -off } else { off }, s))
}
fn tzset_num(s: &str, min: i64, max: i64) -> Option<(i64, &str)> {
let e = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if e == 0 {
return None;
}
let n: i64 = s[..e].parse().ok()?;
if n < min || n > max {
return None;
}
Some((n, &s[e..]))
}
enum RuleKind {
Julian(i64),
Doy(i64),
Mwd(i64, i64, i64),
}
struct Rule {
kind: RuleKind,
time: i64,
}
fn tzset_rule(s: &str) -> Option<(Rule, &str)> {
let (kind, s) = if let Some(r) = s.strip_prefix('J') {
let (d, r) = tzset_num(r, 1, 365)?;
(RuleKind::Julian(d), r)
} else if let Some(r) = s.strip_prefix('M') {
let (m, r) = tzset_num(r, 1, 12)?;
let r = r.strip_prefix('.')?;
let (w, r) = tzset_num(r, 1, 5)?;
let r = r.strip_prefix('.')?;
let (d, r) = tzset_num(r, 0, 6)?;
(RuleKind::Mwd(m, w, d), r)
} else {
let (d, r) = tzset_num(s, 0, 365)?;
(RuleKind::Doy(d), r)
};
let (time, s) = if let Some(r) = s.strip_prefix('/') {
tzset_offset(r)?
} else {
(2 * 3600, s)
};
Some((Rule { kind, time }, s))
}
/// `tzruleTime` : secondes depuis le début de l'année (UTC) où la règle
/// s'applique, pour le décalage en vigueur avant elle.
fn tzrule_time(year: i64, r: &Rule, off: i64) -> i64 {
let s = match r.kind {
RuleKind::Julian(d) => {
let mut s = (d - 1) * 86400;
if is_leap(year) && d >= 60 {
s += 86400;
}
s
}
RuleKind::Doy(d) => d * 86400,
RuleKind::Mwd(mon, week, day) => {
// Zeller : jour de la semaine du 1er du mois.
let m1 = (mon + 9) % 12 + 1;
let mut yy0 = year;
if mon <= 2 {
yy0 -= 1;
}
let yy1 = yy0 / 100;
let yy2 = yy0 % 100;
let mut dow = ((26 * m1 - 2) / 10 + 1 + yy2 + yy2 / 4 + yy1 / 4 - 2 * yy1) % 7;
if dow < 0 {
dow += 7;
}
let mut d = day - dow;
if d < 0 {
d += 7;
}
for _ in 1..week {
if d + 7 >= days_in(mon, year) {
break;
}
d += 7;
}
let mut s = d * 86400;
for m in 1..mon {
s += days_in(m, year) * 86400;
}
s
}
};
s + r.time - off
}
fn days_in(m: i64, year: i64) -> i64 {
match m {
2 => {
if is_leap(year) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heure_locale() {
let hiver = 1_767_268_800; // 2026-01-01 12:00 UTC
let etc = Path::new("/usr/share/zoneinfo/Europe/Paris");
let off = |tz: Option<&str>, etc: &Path| {
Location::local_from(tz.map(str::to_string), etc).offset_at(hiver)
};
assert_eq!(off(None, etc), 3600);
assert_eq!(off(None, Path::new("/nulle/part")), 0);
assert_eq!(off(Some(""), etc), 0);
assert_eq!(off(Some(":"), etc), 0);
assert_eq!(off(Some("UTC"), etc), 0);
assert_eq!(off(Some(":Asia/Kolkata"), etc), 19_800);
assert_eq!(off(Some("America/New_York"), etc), -18_000);
assert_eq!(off(Some(":/usr/share/zoneinfo/Asia/Kolkata"), etc), 19_800);
assert_eq!(off(Some("/nulle/part"), etc), 0);
assert_eq!(off(Some("Nulle/Part"), etc), 0);
}
#[test]
fn noms_de_fuseau() {
let sys = vec!["/usr/share/zoneinfo/".to_string()];
// « UTC » sans base : comme le Go, sans fichier.
assert_eq!(Location::load_in("UTC", &[]).unwrap().name, "UTC");
assert_eq!(Location::load_in("", &[]).unwrap().offset_at(0), 0);
for bad in ["../etc/passwd", "a..b", "/etc/localtime", "\\x"] {
assert_eq!(
Location::load_in(bad, &sys).err().as_deref(),
Some("time: invalid location name"),
"{bad}"
);
}
assert_eq!(
Location::load_in("Europe/Paris", &[]).err().as_deref(),
Some("unknown time zone Europe/Paris")
);
assert!(Location::load_in("Europe/Paris", &sys).is_ok());
}
/// Valeurs tirées du Go.
#[test]
fn transitions_et_normalisation() {
let ny = Location::load("America/New_York").unwrap();
let paris = Location::load("Europe/Paris").unwrap();
// Avant la première transition : l'heure solaire de New York.
assert_eq!(ny.offset_at(-3_000_000_000), -17_762);
// Pas d'heure d'été en France en 1970 : la table, pas la règle.
assert_eq!(paris.offset_at(paris.date(1970, 7, 1, 14, 0, 0)), 3600);
assert_eq!(paris.offset_at(1_782_907_200), 7200);
// Pile à la transition : la nouvelle heure.
assert_eq!(paris.offset_at(828_234_000), 7200);
assert_eq!(paris.offset_at(828_233_999), 3600);
assert_eq!(paris.offset_at(846_378_000), 3600);
assert_eq!(paris.offset_at(846_377_999), 7200);
// L'heure qui saute, à l'ouest de Greenwich.
assert_eq!(ny.date(2026, 3, 8, 3, 30, 0), 1_772_955_000);
assert_eq!(ny.date(2026, 3, 8, 2, 30, 0), 1_772_951_400);
assert_eq!(ny.offset_at(1_782_907_200), -14_400);
// Mois hors bornes, reportés sur l'année.
let u = Location::utc();
assert_eq!(u.date(2026, 0, 15, 10, 0, 0), 1_765_792_800);
assert_eq!(u.date(2026, -13, 15, 10, 0, 0), 1_731_664_800);
}
#[test]
fn calendrier() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(days_from_civil(2026, 10, 15), 20741);
assert_eq!(civil_from_days(20741), (2026, 10, 15));
assert_eq!(civil(0).weekday, 4, "jeudi");
// Mois et jours hors bornes, normalisés comme time.Date.
let u = Location::utc();
assert_eq!(u.date(2026, 13, 1, 0, 0, 0), u.date(2027, 1, 1, 0, 0, 0));
assert_eq!(u.date(2026, 2, 30, 0, 0, 0), u.date(2026, 3, 2, 0, 0, 0));
assert_eq!(u.date(2026, 1, 0, 0, 0, 0), u.date(2025, 12, 31, 0, 0, 0));
}
#[test]
fn paris() {
let Ok(p) = Location::load("Europe/Paris") else {
eprintln!("zoneinfo absente : test sauté");
return;
};
// Hiver : +1 h ; été : +2 h.
let jan = Location::utc().date(2026, 1, 15, 12, 0, 0);
assert_eq!(p.offset_at(jan), 3600);
let jul = Location::utc().date(2026, 7, 15, 12, 0, 0);
assert_eq!(p.offset_at(jul), 7200);
// Bien au-delà de la dernière transition du fichier : la règle POSIX.
let far = Location::utc().date(2090, 7, 15, 12, 0, 0);
assert_eq!(p.offset_at(far), 7200);
let far_w = Location::utc().date(2090, 12, 15, 12, 0, 0);
assert_eq!(p.offset_at(far_w), 3600);
// Heure murale → instant.
let t = p.date(2026, 7, 15, 23, 0, 0);
assert_eq!(p.civil(t).ymd_hm(), "2026-07-15 23:00");
assert_eq!(Location::utc().civil(t).ymd_hm(), "2026-07-15 21:00");
// Heure qui n'existe pas (passage à l'été) : comme le Go, 02:30 → 03:30.
let gap = p.date(2026, 3, 29, 2, 30, 0);
assert_eq!(p.civil(gap).ymd_hm(), "2026-03-29 03:30");
assert_eq!(gap, 1774747800, "instant donné par le Go");
// Heure double (passage à l'hiver) : la seconde, comme l'algorithme de
// time.Date (vérifié contre le Go).
let dbl = p.date(2026, 10, 25, 2, 30, 0);
assert_eq!((dbl, p.offset_at(dbl)), (1792891800, 3600));
assert_eq!(t, 1784149200);
assert!(Location::load("Nulle/Part").is_err());
assert!(Location::load("../etc/passwd").is_err());
}
}
+51 -1
View File
@@ -99,6 +99,16 @@ pub struct FakeGo {
pub crypt_mode: Mutex<CryptMode>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
pub conv_workspace: Mutex<Option<serde_json::Value>>,
/// /internal/v1/conv/workspace crée-t-il la discussion active dans
/// l'index, s'il est vide (`convEnsureActive`) ?
pub conv_ensure: Mutex<bool>,
/// /internal/v1/tasks/run reçus (ids) : la tâche IA ne tourne pas (le
/// modèle n'est jamais prêt ici).
pub task_runs: Mutex<Vec<String>>,
/// Durée d'une tâche IA reçue (rien : rendue tout de suite).
pub task_run_delay: Mutex<Option<std::time::Duration>>,
/// /internal/v1/push/send reçus : (titre, corps).
pub push_sends: Mutex<Vec<(String, String)>>,
}
impl FakeGo {
@@ -472,9 +482,41 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
resp(204, "")
}
"/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() {
Some(v) => resp(200, v.to_string()),
Some(v) => {
if *st.conv_ensure.lock().unwrap() && st.get("chat", "index").is_none() {
let id = v["id"].as_str().unwrap_or("actif");
st.set(
"chat",
"index",
&serde_json::json!([{ "id": id }]).to_string(),
);
}
resp(200, v.to_string())
}
None => resp(404, "404 page not found"),
},
"/internal/v1/tasks/run" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
st.task_runs
.lock()
.unwrap()
.push(v["id"].as_str().unwrap_or("").to_string());
let delay = *st.task_run_delay.lock().unwrap();
if let Some(d) = delay {
tokio::time::sleep(d).await;
}
resp(200, r#"{"busy":true}"#)
}
"/internal/v1/push/send" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
st.push_sends.lock().unwrap().push((
v["title"].as_str().unwrap_or("").to_string(),
v["body"].as_str().unwrap_or("").to_string(),
));
resp(204, "")
}
"/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() {
Some(v) => resp(200, v.to_string()),
None => resp(404, "404 page not found"),
@@ -662,6 +704,10 @@ pub struct Opts {
pub backends: Option<Arc<loki_rs::backends::Backends>>,
pub engineimg: Option<Arc<loki_rs::engineimg::EngineImg>>,
pub strata: Option<Arc<loki_rs::strata::api::Strata>>,
pub tasks: Option<Arc<loki_rs::tasks::Tasks>>,
/// Base de départ, posée avant que la façade démarre : (bucket, clé,
/// valeur).
pub seed: Vec<(String, String, String)>,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -697,6 +743,9 @@ pub async fn start_with(
Option<Arc<loki_rs::chat::generate::ChatService>>,
) {
let st = Arc::new(FakeGo::default());
for (b, k, v) in &opts.seed {
st.set(b, k, v);
}
if let Some(p) = opts.engine_port {
st.set("config", "PORT", &p.to_string());
}
@@ -766,6 +815,7 @@ pub async fn start_with(
backends: opts.backends,
engineimg: opts.engineimg,
strata: opts.strata,
tasks: opts.tasks,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -109,6 +109,7 @@ async fn banc(cle: Option<&str>) -> Banc {
backends: None,
engineimg: None,
strata: None,
tasks: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -115,6 +115,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
backends: None,
engineimg: None,
strata: None,
tasks: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+497
View File
@@ -0,0 +1,497 @@
//! Lot 26 : tâches planifiées tenues par la façade (`LOKI_TASKS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_tasks_test.go` →
//! `tests/parity_tasks/tasks.json`) est rejoué par une façade dont l'heure
//! des tâches est fixée pas à pas et dont les tics du planificateur sont
//! déclenchés à la demande. Le faux Go tient la base, le moteur occupé et la
//! discussion ouverte ; une tâche IA qu'il reçoit ne tourne pas (le modèle
//! n'est jamais prêt ici, comme dans le journal).
#![cfg(unix)]
mod common;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
use common::fakego;
use common::parity::apply_files;
use loki_rs::store::Store;
use loki_rs::tasks::{self, Tasks};
use regex::Regex;
use serde_json::{Value, json};
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_tasks/tasks.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
/// L'heure des tâches du rejeu (ns Unix).
static NOW: AtomicI64 = AtomicI64::new(0);
fn clock() -> i64 {
NOW.load(Ordering::SeqCst)
}
fn rfc3339_ns(v: &str) -> i64 {
let d = &v[..10];
let t = &v[11..19];
let p: Vec<i64> = d.split('-').map(|x| x.parse().unwrap()).collect();
let h: Vec<i64> = t.split(':').map(|x| x.parse().unwrap()).collect();
let utc = loki_rs::tz::Location::utc();
utc.date(p[0], p[1], p[2], h[0], h[1], h[2]) * 1_000_000_000
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
tasks: Arc<Tasks>,
store: Store,
ids: HashMap<String, String>,
order: Vec<String>,
_tmp: tempfile::TempDir,
}
async fn bench(clock: fn() -> i64) -> Bench {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let tasks = Arc::new(Tasks::with(home.clone(), Duration::from_secs(3600), clock));
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
tasks: Some(tasks.clone()),
..Default::default()
},
)
.await;
// Pas de modèle prêt : le port du moteur ne répond pas.
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
go.set("config", "PORT", &port.to_string());
let ws = home.join("workspace").join("actif");
std::fs::create_dir_all(&ws).unwrap();
*go.conv_workspace.lock().unwrap() = Some(json!({
"dir": ws, "root": home.join("workspace"), "id": "actif", "title": ""
}));
*go.conv_ensure.lock().unwrap() = true;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.to_string());
// Le premier passage du planificateur.
for _ in 0..500 {
if tasks.ticks() > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
Bench {
facade,
go,
_stop: stop,
home,
tasks,
store,
ids: HashMap::new(),
order: Vec::new(),
_tmp: tmp,
}
}
impl Bench {
/// Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans
/// l'ordre où ils apparaissent.
fn mask(&mut self, v: &str) -> String {
let id = Regex::new(r"\b[0-9a-f]{12}\b").unwrap();
let dur = Regex::new(r#"exit: (-?[0-9]+) · [^\n"\\]*"#).unwrap();
let created = Regex::new(r#""created":[0-9]+"#).unwrap();
let mut out = String::new();
let mut last = 0;
for m in id.find_iter(v) {
out.push_str(&v[last..m.start()]);
let k = m.as_str().to_string();
if !self.ids.contains_key(&k) {
self.order.push(k.clone());
self.ids
.insert(k.clone(), format!("{{{{ID{}}}}}", self.order.len()));
}
out.push_str(&self.ids[&k]);
last = m.end();
}
out.push_str(&v[last..]);
let out = out.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
let out = created.replace_all(&out, r#""created":"{{T}}""#);
dur.replace_all(&out, "exit: $1 · {{DUR}}").into_owned()
}
fn unmask(&self, v: &str) -> String {
let mut v = v.to_string();
// Les plus longs d'abord : {{ID10}} avant {{ID1}}.
let mut ids: Vec<_> = self.ids.iter().collect();
ids.sort_by_key(|(_, m)| std::cmp::Reverse(m.len()));
for (id, m) in ids {
v = v.replace(m, id);
}
v.replace("{{HOME}}", &self.home.to_string_lossy())
}
async fn call(&mut self, method: &str, path: &str, body: &str) -> (u16, Value) {
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
let raw = String::from_utf8_lossy(&b).into_owned();
let out = serde_json::from_str(&self.mask(&raw))
.unwrap_or_else(|_| panic!("{path} : corps illisible {raw:?}"));
(code.as_u16(), out)
}
async fn snapshot(&mut self) -> Value {
let list = tasks::list_tasks(&self.store).await;
if list.is_empty() {
return Value::Null;
}
let raw = serde_json::to_string(&list).unwrap();
serde_json::from_str(&self.mask(&raw)).unwrap()
}
async fn last_run(&self, id: &str) -> i64 {
tasks::get_task(&self.store, id)
.await
.map_or(0, |t| t.last_run)
}
async fn tick(&self) {
let before = self.tasks.ticks();
self.tasks.kick();
for _ in 0..2000 {
if self.tasks.ticks() > before {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("le tic ne finit pas");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_des_taches() {
let fx = fixture();
let steps = fx["steps"].as_array().unwrap();
NOW.store(rfc3339_ns(s(&steps[0]["now"])), Ordering::SeqCst);
let mut bn = bench(clock).await;
let mut ia_runs = 0;
for st in steps {
let name = s(&st["name"]);
if let Some(n) = st["now"].as_str() {
NOW.store(rfc3339_ns(n), Ordering::SeqCst);
}
let files: serde_json::Map<String, Value> = st["files"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
for (k, v) in st["config"].as_object().into_iter().flatten() {
bn.go.set("config", k, s(v));
}
for (k, v) in st["state"].as_object().into_iter().flatten() {
bn.go.set("state", k, s(v));
}
if let Some(t) = st["put_task"].as_str() {
let v: Value = serde_json::from_str(t).unwrap();
bn.go.set("tasks", s(&v["id"]), t);
}
*bn.go.tune_busy.lock().unwrap() =
(st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string());
let generating = st["generating"].as_bool() == Some(true);
*bn.go.generating.lock().unwrap() = generating;
if st["tick"].as_bool() == Some(true) {
bn.tick().await;
}
let before = match st["wait_done"].as_str() {
Some(id) => bn.last_run(&bn.unmask(id)).await,
None => 0,
};
if let Some(path) = st["path"].as_str() {
let body = bn.unmask(s(&st["body"]));
let (code, out) = bn.call(s(&st["method"]), path, &body).await;
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
assert_eq!(out, st["out"], "{name}");
}
*bn.go.generating.lock().unwrap() = false;
*bn.go.tune_busy.lock().unwrap() = None;
if let Some(want) = st["wait_running"].as_str() {
let mut ok = false;
for _ in 0..1000 {
let (_, out) = bn.call("GET", "/api/tasks", "").await;
if out["running_id"] == json!(want) {
ok = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(ok, "{name} : la tâche ne tourne pas");
}
if let Some(id) = st["wait_done"].as_str() {
let id = bn.unmask(id);
let mut ok = false;
for _ in 0..2000 {
if bn.last_run(&id).await != before {
ok = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(ok, "{name} : le passage ne finit pas");
// Le registre des scripts est vidé juste après l'écriture.
while bn.tasks.script_running() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
let snap = bn.snapshot().await;
assert_eq!(snap, st["tasks"], "{name} : tâches");
// Une tâche IA due part au Go, qui ne la fait pas tourner (modèle pas
// prêt) : son état ne bouge pas.
let runs = bn.go.task_runs.lock().unwrap().clone();
if runs.len() != ia_runs {
assert_eq!(name, "tic : une tâche IA due attend le modèle");
assert_eq!(runs.len(), ia_runs + 1, "{name}");
let masked = bn.mask(runs.last().unwrap());
assert_eq!(masked, "{{ID1}}", "{name}");
ia_runs = runs.len();
}
}
assert_eq!(ia_runs, 1, "la tâche IA due n'est jamais partie au Go");
// Les notifications des scripts partent au Go (la façade ne les tient
// pas) : succès, échec, puis les passages du planificateur.
let sends = bn.go.push_sends.lock().unwrap().clone();
let titles: Vec<&str> = sends.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
titles.first().copied(),
Some("Loki · tâche terminée"),
"{sends:?}"
);
assert!(
sends
.iter()
.any(|(t, b)| t == "Loki · tâche en échec" && b == "K échec"),
"{sends:?}"
);
assert!(
!sends.iter().any(|(_, b)| b == "L lente"),
"un arrêt manuel ne notifie pas : {sends:?}"
);
}
/// Une tâche IA lancée à la main part au Go, qui l'exécute ; le moteur prêt
/// (preset externe), la façade ne la refuse pas.
#[tokio::test]
async fn tache_ia_executee_par_le_go() {
let mut bn = bench(tasks::now_ns).await;
bn.go.set("config", loki_rs::engstatus::EXT_KEY_FLAG, "1");
let (code, out) = bn
.call(
"POST",
"/api/tasks/save",
r#"{"name":"IA","prompt":"Fais le point.","schedule":"@every 1h","enabled":true}"#,
)
.await;
assert_eq!(code, 200, "{out}");
let id = bn.unmask(s(&out["id"]));
// Le Go tient la tâche un moment : l'écran la montre en cours.
*bn.go.task_run_delay.lock().unwrap() = Some(Duration::from_millis(400));
let (code, out) = bn
.call("POST", "/api/tasks/run", &json!({ "id": id }).to_string())
.await;
assert_eq!((code, out), (200, json!({ "ok": true })));
let masked = bn.mask(&id);
let mut seen = false;
for _ in 0..200 {
let (_, v) = bn.call("GET", "/api/tasks", "").await;
if v["running_id"] == json!(masked) {
seen = true;
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(seen, "la tâche IA n'est pas montrée en cours");
for _ in 0..500 {
let (_, v) = bn.call("GET", "/api/tasks", "").await;
if v["running_id"] == json!("") {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
let (_, v) = bn.call("GET", "/api/tasks", "").await;
assert_eq!(v["running_id"], json!(""));
assert_eq!(*bn.go.task_runs.lock().unwrap(), vec![id.clone()]);
// Le Go ne l'a pas fait tourner (occupé) : rien n'est enregistré ici.
let t = tasks::get_task(&bn.store, &id).await.unwrap();
assert_eq!(
(t.last_run, t.last_ok, t.last_report.as_str()),
(0, false, "")
);
// Arrêt d'une tâche IA : la génération du Go.
let (code, _) = bn
.call("POST", "/api/tasks/stop", &json!({ "id": id }).to_string())
.await;
assert_eq!(code, 200);
assert_eq!(*bn.go.lease_log.lock().unwrap(), vec!["stop".to_string()]);
let (code, _) = bn
.call("POST", "/api/tasks/run", r#"{"id":"absente"}"#)
.await;
assert_eq!(code, 404);
}
/// Sans demande, le planificateur tourne à son pas.
#[tokio::test]
async fn tics_au_pas_du_planificateur() {
let tmp = tempfile::tempdir().unwrap();
let t = Arc::new(Tasks::with(
tmp.path().to_path_buf(),
Duration::from_millis(20),
tasks::now_ns,
));
let (_facade, _go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
tasks: Some(t.clone()),
..Default::default()
},
)
.await;
for _ in 0..500 {
if t.ticks() >= 4 {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("le planificateur ne tourne pas : {} tics", t.ticks());
}
/// Sans la façade pour les tâches, leurs routes partent au Go.
#[tokio::test]
async fn sans_la_facade() {
let tmp = tempfile::tempdir().unwrap();
let (facade, go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
let (c, b) = fakego::send(facade, "GET", "/api/tasks", &[], "").await;
assert_eq!(c.as_u16(), 200);
assert_eq!(String::from_utf8_lossy(&b), "go:/api/tasks");
assert!(
go.relayed
.lock()
.unwrap()
.contains(&"/api/tasks".to_string())
);
}
/// Une horloge qui avance d'une seconde et demie à chaque lecture.
static TICKING: AtomicI64 = AtomicI64::new(1_774_738_800_000_000_000);
fn ticking() -> i64 {
TICKING.fetch_add(1_500_000_000, Ordering::SeqCst)
}
/// La durée d'un passage : de son début à sa fin, à l'horloge des tâches.
#[tokio::test]
async fn duree_du_passage() {
let mut bn = bench(ticking).await;
apply_files(
&bn.home,
&json!({ "scripts/ok.sh": "#!/bin/sh\necho bonjour\n" }),
);
let (code, out) = bn
.call(
"POST",
"/api/tasks/save",
r#"{"name":"S","kind":"script","script":"ok.sh","schedule":"@every 1h","enabled":true}"#,
)
.await;
assert_eq!(code, 200, "{out}");
let id = bn.unmask(s(&out["id"]));
let (code, _) = bn
.call("POST", "/api/tasks/run", &json!({ "id": id }).to_string())
.await;
assert_eq!(code, 200);
let mut t = None;
for _ in 0..1000 {
t = tasks::get_task(&bn.store, &id)
.await
.filter(|t| t.last_run > 0);
if t.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let t = t.expect("le passage ne finit pas");
assert!(t.last_ok, "{t:?}");
assert!(
t.last_dur_ms > 0 && t.last_dur_ms % 1500 == 0,
"durée : {}",
t.last_dur_ms
);
}
/// Au démarrage, le prochain passage des tâches actives qui n'en ont pas
/// (`primeNextRuns`) ; les autres ne bougent pas.
#[tokio::test]
async fn amorce_au_demarrage() {
let tmp = tempfile::tempdir().unwrap();
let t = Arc::new(Tasks::with(
tmp.path().to_path_buf(),
Duration::from_secs(3600),
tasks::now_ns,
));
let task = |id: &str, enabled: bool, next: i64| {
(
"tasks".to_string(),
id.to_string(),
json!({ "id": id, "name": id, "kind": "script", "script": "ok.sh",
"schedule": "@every 1h", "enabled": enabled, "next_run": next })
.to_string(),
)
};
let (_facade, go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
tasks: Some(t.clone()),
seed: vec![
task("active", true, 0),
task("eteinte", false, 0),
task("posee", true, 42),
],
..Default::default()
},
)
.await;
for _ in 0..500 {
if t.ticks() > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(t.ticks() > 0, "pas de premier passage");
let next = |id: &str| -> i64 {
let v: Value = serde_json::from_str(&go.get("tasks", id).unwrap()).unwrap();
v["next_run"].as_i64().unwrap()
};
let now_ms = tasks::now_ns() / 1_000_000;
let a = next("active");
assert!(
(now_ms + 3_590_000..=now_ms + 3_600_000).contains(&a),
"active : {a}"
);
assert_eq!(next("eteinte"), 0);
assert_eq!(next("posee"), 42);
}