Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent (#33)

Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-16 09:31:59 +02:00
commit 00e09a71e3
348 files changed
+259091 -701

No files matched your search

+2
View File
@@ -53,6 +53,8 @@ WORKDIR /src
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
# deux binaires d'une image servent toujours la même interface.
COPY internal/loki/ui/ internal/loki/ui/
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
COPY rust/ rust/
RUN cd rust && cargo build --release --locked \
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
+20 -1
View File
@@ -227,7 +227,26 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut (outils fichiers, shell et git compris ; le Go garde les autres outils, les pièces jointes, les fichiers et l'export). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go la garde de toute façon (décidé au démarrage) | Rust |
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `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 ; les tours de la façade appellent son pool directement (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, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, 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 ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (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 (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -0,0 +1,44 @@
# Lot 10 — réglages partagés tenus par la façade
## Contexte
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
tous les appareils :
| Route | Port de | Ce qu'elle règle |
|---|---|---|
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
expérimental ; sans la variable rien ne change).
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
## Écarts assumés
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
- Préférences réécrites clé par clé au lieu d'une transaction.
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
par `/etc/services`.
## Parité
`internal/loki/parity_settings_test.go` tire du Go :
- 19 adresses publiques normalisées ou refusées ;
- 11 valeurs d'intensité ;
- 29 appels de route, avec le contenu de la base après chacun :
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
- prompt par preset avec repli, effacement, méthode refusée ;
- intensité acceptée ou refusée ;
- adresse publique.
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
tronqué à 25 caractères) fait échouer le rejeu.
@@ -0,0 +1,63 @@
# Lot 11 — fichiers de la discussion tenus par la façade
## Contexte
Les fichiers rangés par discussion vivaient dans le Go :
- `web_upload.go` : dépôt, téléchargement, image ;
- `web_files.go` : panneau Fichiers ;
- `web_files_zip.go` : archive ;
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
- `chat_convfiles.go` : chemins.
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
Le dossier de la discussion active reste décidé par le Go : la façade le lui
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
conversation ou que ce soit la façade. Les liens des anciens messages
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
`.part` d'envois au démarrage : la façade le fait au sien.
## Écarts assumés
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
redressement et la réduction (algorithme porté pixel pour pixel) sont les
mêmes.
- Horodatage des entrées `.zip` en UTC.
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
plusieurs, en multipart.
## Parité
`internal/loki/parity_files_test.go` tire du Go :
- 10 noms de dépôt et 6 slugs ;
- 6 en-têtes EXIF ;
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
- 10 reniflages ;
- 42 appels de route sur une arborescence fixe, dates comprises :
- panneau, téléchargement, image et vignette ;
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
base64 illisible ;
- suppressions ;
- archive de la discussion et « hors discussion », contenu comparé
entrée par entrée ;
- les fichiers restants après chaque POST.
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
le rejeu.
@@ -0,0 +1,47 @@
# Lot 12 — notifications Web Push tenues par la façade
## Contexte
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
| `POST /api/push/unsubscribe` | le retire |
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
rustls. Une façade construite sans TLS répond que les notifications sont
indisponibles.
## Écarts assumés
- Messages des journaux d'envoi au format des crates Rust.
## Parité
- `internal/loki/parity_push_test.go` tire du Go :
- les durées du corps de notification ;
- onze appels de route (clé, abonnements valides et refusés, mise à jour
des clés, désabonnements), avec la liste en base après chacun.
`tests/push.rs` rejoue le tout par la façade. La liste en base est
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
près.
- Un envoi de bout en bout vers un faux service de push vérifie :
- les en-têtes ;
- la signature VAPID, contrôlée avec la clé rangée ;
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
- l'abonnement en 410, purgé.
@@ -0,0 +1,117 @@
# Lot 13 — mémoire tenue par la façade
## Contexte
La mémoire de Loki vivait dans le Go :
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
- `tracker.go` et `web_tracker.go` pour les trackers ;
- les routes du mode agent.
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
### Disque et base
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
restent sûres, comme dans le Go :
- un `.bak` de la version précédente est gardé ;
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
- le contenu est relu et comparé octet par octet.
Les trackers restent dans le bucket `tracker`, sous la clé
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
quel. Le mode mémoire y est écrit avec l'échappement HTML de
`encoding/json`, pour garder les mêmes octets en base.
### Chiffrement
La clé de la mémoire ne quitte pas le Go :
- une page qui porte le magic `LOKIMEMv1` est ouverte par
`/internal/v1/crypt/open` ;
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
qui rend le clair tel quel quand le chiffrement est coupé.
Quand la mémoire est verrouillée :
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
verrouillée) » ;
- les trackers illisibles sont tus ;
- aucune écriture ne retombe en clair.
### Messages d'erreur JSON
Ces routes renvoient au client le message d'erreur de décodage du Go.
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
- seule la première valeur compte ;
- `EOF` et `unexpected EOF` ;
- les erreurs du scanner (`invalid character …`) ;
- les erreurs de type, champs anonymes compris ;
- la casse des noms de champs, et l'allocation des pointeurs.
`gojson.rs` est mutualisé pour les lots suivants.
## Restent au Go
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
- La santé, les instantanés, la sauvegarde.
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
RAM du Go : la façade sert toujours le projet actif rangé.
## Écarts assumés
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
invalide, nombre hors limites) donne le message de serde.
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
`mktime`.
## Parité
### Décodage JSON
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
et message d'erreur. `tests/gojson.rs` les rejoue.
### Mémoire
`internal/loki/parity_mem_test.go` tire du Go :
- les noms de pages, les titres, les slugs et les modes mémoire ;
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
- 102 appels de route, avec après chacun le dossier `memory/` complet
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
mode mémoire, projets, trackers).
Ces appels couvrent :
- les pages : création, renommage, réenregistrement qui perd l'accroche,
noms refusés, fichiers cachés et dossiers ignorés ;
- le déplacement, avec ses six refus ;
- la bascule de projet ;
- le mode agent et le compactage ;
- le mode mémoire, y compris un projet actif inconnu ;
- les trackers : ajout, modification, suppression, renommage, déplacement,
valeurs illisibles et événements nuls.
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
identifiants et des instants « maintenant ».
Une mutation de chaque harnais fait échouer son rejeu.
Un essai de bout en bout simule le chiffrement par le faux Go :
- les pages sont écrites scellées et relues en clair ;
- l'index reste en clair ;
- les trackers sont scellés ;
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
@@ -0,0 +1,97 @@
# Lot 14 — `loki serve` composé en Rust
## Contexte
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
la ligne de commande de llama-server avant de lui céder la place.
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
y a été réglée contre un défaut mesuré :
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
- threads et sonde de conteneur ;
- files de lancement CUDA ;
- second slot, parallélisme de tenseurs ;
- cache de prompts, points de reprise des hybrides ;
- décodage spéculatif (MTP, brouillon, n-grammes) ;
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
- état des slots gardé à la bascule.
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
variable, rien ne change.
## Découpage
- `serve/` : port fonction par fonction. `mod.rs` contient
`build_serve_args` et les outils de drapeaux. Les modules suivent les
fichiers Go :
- `threads`, `queues`, `expert`, `fidelity` ;
- `side`, `split`, `cache`, `ckpt` ;
- `spec`, `moe`, `persist`.
Tout ce qui décide est pur.
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
du Go. Il sonde ensuite :
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
avec les bibliothèques du moteur) ;
- le GGUF ;
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
- les variables `LLAMA_ARG_*` et `GGML_*`.
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
`exec` du moteur.
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
## Restent au Go
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
- un preset Strata (son propre serveur, lot à venir) ;
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
- l'absence d'API interne (lancement à la main hors de la façade).
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
des presets.
## Écarts assumés
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
au format Rust.
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
sans transaction : le Go seul l'efface, une fois le moteur prêt.
## Parité
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
avec sa ligne de commande, son environnement et ses notes. Les lancements
croisent :
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
- 112 presets ;
- 3 aides de moteur (récent, ancien, inconnu) ;
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
Le journal comprend aussi les sondes pures :
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
- forme copiable de la commande ;
- verdicts de SPEC=auto ;
- drapeaux de chargement, taille d'état par jeton.
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
des n-grammes fait échouer le rejeu.
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
faux Go et un faux llama-server. Ils vérifient :
- la ligne exécutée et le chemin des bibliothèques ;
- la commande gardée en base ;
- le refus d'un port pris, d'un modèle absent ;
- la sortie propre d'un preset externe ;
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
@@ -0,0 +1,86 @@
# Lot 15 — presets tenus par la façade
## Contexte
Les presets vivaient dans le Go :
- `backend_presets.go` pour l'écriture ;
- les handlers de `web_api.go` et `web_external.go` ;
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
`moePreview`, `autoPlacementPreset`).
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
| `POST /api/switch` | bascule de preset |
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
sondes comprises.
## Restent au Go
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
expose deux routes internes pour cela (`facade_presets.go`).
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
configuration vivante :
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
est relancé, ou arrêté pour un preset externe ;
- `save` : application, puis relance ;
- `apply` : la configuration seule (preset externe en service modifié).
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
## Écarts assumés
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
pareil.
- Le message d'un échec de réseau du test externe est celui de reqwest.
## Parité
`internal/loki/parity_presets_test.go` tire du Go deux séries.
Les fonctions pures :
- nom d'affichage, guillemets, format `.env`, noms refusés ;
- quantification, raisonnement, modèle ;
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
- arguments rejoints, clé réécrite dans un preset ;
- huit copies en placement auto ;
- empreinte de mesure, moteur imposé par un preset.
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
`models/`, et les clés touchées en base. Ils couvrent :
- créations, doublons, noms refusés, erreurs JSON ;
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
configuration) ;
- preset actif et suppressions (modèle compris, partagé, absent) ;
- presets externes et leur test contre un faux service (succès, 401 au
format OpenAI, 404 brut, 500 vide) ;
- placement auto et aperçus.
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
échouer le rejeu.
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
bout contre le faux Go :
- appels d'application, avec leur mode ;
- refus quand une optimisation tourne ;
- erreur d'application rendue ;
- prompt système.
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
refusé.
@@ -0,0 +1,90 @@
# Lot 16 — pilotage du moteur tenu par la façade
## Contexte
Le Go pilotait le moteur depuis l'interface :
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
`/api/restart` ;
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
carte graphique à la machine, puis la reprendre ;
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
`sys_service_rust_linux.go`) ;
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
de le lancer.
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
arrête alors le moteur local.
Le bilan de VRAM suit `gpuSettle` :
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
- un palier ne conclut qu'une fois la baisse commencée ;
- les jauges du moniteur sont invalidées après coup.
## Quand la façade ne tient pas ce lot
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
le journal).
## Restent au Go
Le Go expose trois routes internes (`facade_engine.go`) :
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
application interrompue défaite). Le verrou de l'optimiseur suit le
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
la conversation (sinon la façade lit la sienne) ;
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
tient (sinon la façade arrête la sienne).
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
Les autres appelants de `serviceAction` côté Go (bascule de preset,
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
lancent le même superviseur, sur les mêmes fichiers PID et journal.
## Écarts assumés
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
killed ») pour les signaux courants, par son numéro sinon.
## Parité
`internal/loki/parity_engine_test.go` tire du Go :
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
hausse…) ;
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
- vingt-six installations passées au contrôle d'avant démarrage (preset
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
remontant, en tranches incomplètes…) ;
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
bout, avec un faux superviseur et un faux nvidia-smi :
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
- échec du démarrage, preset externe ;
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
- rechargement.
Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
@@ -0,0 +1,92 @@
# Lot 17 — interrupteurs système tenus par la façade
## Contexte
Quatre réglages de l'interface vivaient dans le Go :
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
- le pilotage du navigateur : `handleComputer`, `chromePath`,
`visionEnabled` ;
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
`oaiBaseURL` ;
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
configuration.
La joignabilité de Crawl4AI suit `crawlReachable` :
- `/health`, puis `/`, en moins de deux secondes en tout ;
- clé envoyée en `Authorization: Bearer` ;
- moins de 500 = joignable ;
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
## Restent au Go
Le Go expose trois routes internes (`facade_system.go`) :
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
web, encore exécutés par le Go, est vidé à chaque changement ;
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
ferme quand l'interrupteur se coupe ;
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
optimisation.
Le relais du tunnel vers la façade garde désormais l'hôte et
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
que le client a tapée, pas celle de la façade.
## Correction en passant
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
Rust.
## Écarts assumés
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
conteneur Linux, où le Go répondait déjà « inconnu ».
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
devient U+FFFD, octet par octet, comme son encodeur JSON.
## Parité
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
après chacun les clés touchées en base et les en-têtes reçus par trois faux
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
- erreurs de type qui laissent passer les autres champs, `null` ;
- vision par projecteur, preset externe ou Strata ;
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
optimisation ;
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
retenu.
`tests/system.rs` rejoue le tout par une seule façade, cache de
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
les tests.
Côté Go :
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
@@ -0,0 +1,78 @@
# Lot 18 — hub des projets tenu par la façade
## Contexte
Le hub des projets vivait dans le Go :
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
- les mutations de `projects.go` : `createProject`, `renameProject`,
`setProjectDesc`, `deleteProject` ;
- `projectSwitch` (`chat_sessions.go`).
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
| Action | Rôle |
|---|---|
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
| `desc` | 2 000 runes au plus, vide = effacée |
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
## Restent au Go
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
Les discussions sont à qui tient la conversation.
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
et en ouvre une du premier projet restant.
Sinon le Go garde deux routes internes (`facade_projects.go`) :
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
- `POST /internal/v1/project/drop-convs` : les discussions du projet
supprimées (`convDelete`, fichiers et résultats compris).
Une génération en cours refuse la bascule et la suppression (409). La façade
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
## Écarts assumés
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
la façade lit le projet actif enregistré.
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
comme quand le Go tient la conversation. Le Go, relayant à la façade,
répondait 400 (« projet introuvable ») dans ce cas.
## Parité
`internal/loki/parity_projects_test.go` part d'une base amorcée :
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
projet disparu ;
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
- créations (doublons, translittération, sans lettres, longues, vides) ;
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
une génération) ;
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
dernier projet) ;
- actions inconnues, corps vide ou illisible, erreur de type, méthode
refusée.
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
bascule et supprime les discussions comme le vrai. La bascule et la
suppression quand la façade tient la conversation sont vérifiées de bout en
bout. Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
@@ -0,0 +1,65 @@
# Lot 19 — export, mode Code et critères de la discussion
## Contexte
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
routes de la discussion ouverte restaient au Go :
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
qui peut avoir un temps de retard sur la conversation vivante ;
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
ses critères ;
- `/api/chat/criteria` : critères édités à la main.
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
L'export suit `chat_export.go` :
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
les outils en liste (seul l'état final d'un outil compte, comme au
rejeu), la compaction signalée, un export partiel annoncé en tête ;
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
sa taille), journal filtré par les mêmes cases ;
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
des rappels de Loki.
## Restent au Go
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
## Écarts assumés
- L'export part de la conversation vivante, pas de sa copie enregistrée :
le dernier tour y est, même tout juste fini.
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
conversation relue par le Go les avait déjà perdues.
## Parité
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
- pièces jointes, raisonnement en deltas ;
- outils finis, en cours, avec et sans sortie, labels piégés pour le
Markdown ;
- statistiques, compaction ;
- images en base64 et en URL, rappel de Loki ;
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
Plus les exports d'une conversation vide.
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
les événements du fil après chacun : erreurs JSON, y compris celles d'un
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
les tests.
@@ -0,0 +1,93 @@
# Lot 20 — serveurs MCP tenus par la façade
## Contexte
Le Go tenait les serveurs MCP (Model Context Protocol) :
- `mcp_config.go` : la configuration, dans `state/mcp` ;
- `mcp_catalog.go` : le catalogue embarqué ;
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
- les routes `/api/mcp*` de `web_api.go`.
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
| `POST /api/mcp/test` | reconnexion forcée |
## Client MCP en Rust
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
- stdio : un processus local, un message par ligne ;
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
serveur reçoit sa réponse.
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
- une session gardée d'un tour à l'autre ;
- une entrée publiée avant la connexion, pour que deux appelants partagent
la même tentative ;
- une session invalidée pendant sa connexion n'est jamais republiée ;
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
- un appel borné à 120 s, une reconnexion si la session est morte ;
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
## Le Go
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
l'agent, lui demande les outils à annoncer, leurs appels et le
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
pool reste vide.
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
Rust : un seul catalogue pour les deux binaires.
## Écarts assumés
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
Go.
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
l'ordre alphabétique.
## Parité
`internal/loki/parity_mcp_test.go` tire du Go :
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
d'origine d'un outil, ligne du préambule, délais et leurs messages,
aplatissement de neuf résultats ;
- la lecture de la configuration et seize mutations avec leurs erreurs et la
base après chacune ;
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
onze outils, et le résultat de chacun ;
- ce que le client Go en tire : état de quatre serveurs (joignable,
binaire introuvable, désactivé, mal configuré), outils annoncés (un
masqué), appels.
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
outils et les appels tirés par la façade doivent être ceux du Go, sur une
seule session. Sont aussi vérifiés de bout en bout :
- le transport stdio : environnement, ping du serveur, serveur mort en plein
appel puis reconnexion ;
- les routes de configuration, en-têtes compris ;
- le catalogue ;
- une session invalidée pendant sa connexion, sans processus laissé
derrière.
Une mutation de chaque règle fait échouer les tests. Côté Go,
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
pré-chauffage et une façade injoignable.
@@ -0,0 +1,26 @@
# Lot 21 — mise à jour de Loki tenue par la façade
## Contexte
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
et les deux routes le disent (`containerUpdateNote`) :
- `/api/update` : `{current, available: false, note}` ;
- `/api/update/apply` : 409 `{ok: false, error: note}`.
## Le lot
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
et le remplacement du binaire hors conteneur.
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
la façade suivent toujours `loki-rs self-update`.
## Parité
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
la façade, version du Go comprise.
@@ -0,0 +1,111 @@
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
## Contexte
Le panneau Moteur s'appuie sur une famille de routes du Go :
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
- `backend_prebuilt.go` : binaires précompilés officiels ;
- `web_api.go` : backends custom et leur désinstallation ;
- `web_devices.go` : GPU vus par un moteur.
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
du job est en base (`state/build_job`), ses lignes dans
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
interrompu (`lcRestore`).
## Le lot
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
| `/api/backends`, `/api/backends/custom` | les backends présents |
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
Ces jobs sont :
- la compilation depuis les sources (`install`, `update`, `install-custom`,
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
- la mise à jour du moteur de l'image ;
- Strata.
Côté Go (`facade_backends.go`), quand la façade tient le lot :
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
`lcJobSnapshot` passent par la façade ;
- le garde de l'optimiseur reste au Go, avant le démarrage ;
- le Go ne restaure plus de job au démarrage ;
- les routes reçues par le tunnel sont relayées à la façade.
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
injectables (`backends/probe.rs`) :
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
## Correction du Go
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
Hors CUDA Windows, la version CUDA est vide : le marqueur
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
reconnu, et chaque vérification retéléchargeait la release sous Linux et
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
champ, et la version CUDA ignore ce champ. Le port Rust suit.
## Parité
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
`/opt/rocm` existent sur la machine.
Le journal contient :
- **le statut** dans tous ses états :
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
- origine perdue, BIN dans un fork ;
- précompilé versionné, moteur téléchargé, moteur de l'image ;
- **les backends custom** et chaque refus de désinstallation ;
- **la bascule fast/opt** ;
- **la dernière release** :
- refus HTTP, tag vide, JSON illisible ;
- aucun asset adapté ;
- **le job précompilé** :
- installation, « déjà à jour », ancien format réextrait ;
- optimisation en cours, GitHub qui refuse ;
- archive absente, archive piégée (`../`), archive sans serveur ;
- **les GPU** :
- liaison PCIe, mémoire complétée par nvidia-smi ;
- cache, mémoire nulle non figée, repli sur le disque ;
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
- **le job pas à pas** :
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
- écritures en base limitées à une toutes les 2 s ;
- refus d'un second job ;
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
Autres tests :
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
l'API interne et le service arrêté puis relancé autour de l'installation.
## Écarts assumés
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
- Quand un asset de la release a un champ de mauvais type, le message n'est
pas celui du Go.
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
plus ses lignes dans ce job.
@@ -0,0 +1,85 @@
# Lot 23 — sonde de gabarit portée en Rust
## Contexte
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
modèle chargé fait de ce qu'on lui envoie :
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
préfixe exact du suivant ?
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
Le Go la lance après chaque complétion du fil principal, avec les outils et
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
- le résumé des performances (`/api/perf/summary`).
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
servent le panneau Moteur, autour d'une mise à jour.
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
ne tournait :
- l'écho décidait sans verdict ;
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
## Le lot
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
clé du moteur et les relances sur 503) ;
- les rendus synthétiques et leurs verdicts ;
- le cache par build, modèle, gabarit et forme ;
- le dernier verdict, global et par forme ;
- la ligne de journal ;
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
- le relevé du rendu et sa comparaison.
Avec `LOKI_CHAT=rust` :
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
- **la politique d'écho** lit le dernier verdict ;
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
## Parité
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
Les comportements de moteur couverts :
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
ignorée ;
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
- 503 relancés ou persistants, 429 ;
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
- rendus incohérents, preset externe.
Le journal contient aussi :
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
- le cache : même moteur, gabarit changé, build changé ;
- le relevé du rendu sur trois gabarits, et une erreur ;
- neuf comparaisons de relevés ;
- l'empreinte de six formes.
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
Il vérifie aussi le déclenchement borné.
Autres tests :
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
complétions signalées ;
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
relit le verdict de la façade.
@@ -0,0 +1,130 @@
# Lot 24 — panneau « moteur de l'image » tenu par la façade
## Contexte
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
l'image. Le moteur est pris dans l'image de conteneur officielle
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
servent :
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
suppression ;
- `web_engine_reco.go` : version recommandée, risque de rendu du
raisonnement (défaut de b10763), retour à la version précédente,
vérification du rendu du gabarit avant/après la bascule ;
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
couches, essai à blanc.
La mise à jour est un job, le job des moteurs que la façade tient depuis le
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
de gabarit, portée au lot 23.
## Le lot
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
La sonde de gabarit est celle de la conversation quand la façade la tient
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
le Go (`/internal/v1/service/action`).
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
tunnel.
## Parité
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
rejoué par `tests/engineimg.rs`. Il tourne contre :
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
couches .tar.gz ;
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
Le journal couvre :
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
précédente (absente quand c'est le moteur courant), le relevé du rendu,
sans moteur d'image, sans aucun moteur ;
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
illisible, manifeste refusé ou illisible, aucune image pour
l'architecture) ;
- **le plan** :
- dernière version (avec ou sans numéro) ou recommandée (build trop
ancien, tag figé absent, sans numéro), et déjà à jour ;
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
qui garde la réflexion, build antérieur au défaut, preset externe ;
- **la mise à jour** :
- couches du sommet vers la base ; une couche du dessus masque celle du
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
ignorée ; phase du téléchargement ;
- essai à blanc ;
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
(moteur local arrêté) ;
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
configuration, déjà posée, ou inconnue du moteur ;
- ménage : versions triées par build, dossier sans moteur épargné, version
précédente gardée ;
- rendu comparé avant/après : changé, identique, ou impossible sur le
nouveau moteur ;
- déjà à jour, corps illisible ;
- **les échecs** :
- optimisation en cours, version inconnue, image sans serveur ou sans
version annoncée, manifeste sans couche, runtime au sommet, couche
introuvable, tag illisible ;
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
voit aucune carte ;
- **les bascules** : vers une version installée, vers l'image, vers une
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
- **la suppression** : version (`../` neutralisé), version absente, moteur
utilisé ;
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
l'arborescence de `engine/`.
Autres tests :
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
« non vérifié à temps », le 503 sans backends, les variantes et les
vérifications périmées ;
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
façade, et elles seulement.
## Mutations
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
- la garde « moteur d'image absent » de la source : `same_path` ne
rapproche jamais un chemin vide ;
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
est le nouveau : même résultat ;
- une vérification du rendu périmée qui continue d'attendre son moteur : son
relevé est de toute façon écarté au rangement.
## Écarts assumés
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
Go : le nom Go du type anonyme n'est pas reproduit.
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
@@ -0,0 +1,121 @@
# Lot 25 — Strata tenu par la façade
## Contexte
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
`backend_strata.go` sert trois routes :
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
la recommandation par version et les modèles installés, avec leur
configuration de lancement en clair ;
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
service, la configuration est réappliquée et le moteur relancé ;
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
## Le lot
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
(`rust/loki-rs/src/strata`) sert les trois routes :
- **la décision pure** (`strata/mod.rs`) :
- placement des experts : drop, ram, arena ou disk, selon la RAM
disponible, la carte d'appoint et `experts.bin` ;
- configuration de lancement : réglages validés, carte d'aide, mode des
experts, réglages du preset ;
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
- **la machine** (`strata/api.rs`) :
- cartes via `nvidia-smi` (la sonde des backends) ;
- Python 3.10+ et son module venv ;
- RAM totale et disponible (`/proc/meminfo`) ;
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
- place libre ;
- **l'installation** : le job des moteurs. La sortie de l'installeur est
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
montrée que toutes les 10 s. La bascule finale passe par le Go
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
relance le service.
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
délègue pour un preset Strata).
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
tunnel.
## Correction du Go
`/api/strata/settings` décidait si le preset était en service **après** y
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
correspondait alors plus à la configuration active : changer un réglage du
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
« En service » se décide désormais avant l'écriture, comme dans
`SavePresetApplying`. Le port Rust suit.
## Parité
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
`serviceActionOSFn`. Le journal couvre :
- **la machine** :
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
petite, carte d'aide trop ancienne ;
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
arrondie au dixième) ;
- Python absent, en panne, illisible, trop ancien, sans venv ;
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
présentes ;
- **les réglages** :
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
llama.cpp ;
- hors limites, experts inconnus ;
- preset inactif ; preset actif (appliqué et relancé), et pendant une
optimisation ;
- **l'installation** :
- GET, corps illisible, quant inconnu, optimisation en cours ;
- machine sans carte, modèle qui ne tient pas ;
- release introuvable, moteur altéré (empreinte) ;
- installation complète, puis réinstallation (sources déjà là et
décompressées, moteur téléchargé périmé retéléchargé) ;
- installeur en échec, installeur sans configuration ;
- **les modèles installés** :
- détails de lancement du modèle actif et de modèles inactifs (experts
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
- mode du dernier lancement ;
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
coupé dans le preset ;
- configuration de lancement disparue ou illisible ;
- **après chaque appel** : les actions du service, les clés de la
configuration et l'arborescence de `presets/` et `strata/` (sans le
contenu des archives).
Autres tests :
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
configuration de lancement, placement des experts, recommandation,
réglages, noms ;
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
à la Go ;
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
elles seules.
## Mutations
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
## Écarts assumés
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
message du Go, qui ouvre la source puis la destination.
@@ -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é.
@@ -0,0 +1,151 @@
# Lot 27 — Banc d'essai tenu par la façade
## Contexte
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
`/v1/chat/completions`, avec le gabarit, le raisonnement et
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
modes :
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
prompt, ~300 générés) ;
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
jetons neufs. La reprise du cache est lue dans la réponse, jamais
supposée.
Il tourne en tâche de fond derrière quatre routes :
- `POST /api/bench` lance (202 et un id) ;
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
- `POST /api/bench/cancel` annule ;
- `GET /api/bench/last` rend la dernière mesure enregistrée.
Pendant la mesure, le bench tient le verrou de génération de la
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
le refus du bench. Seule une mesure complète est enregistrée : la
dernière, et celle du preset actif.
## Le lot
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
sert les quatre routes et déroule le protocole contre le moteur :
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
même source ;
- les mêmes requêtes ;
- le même calcul de profondeur, de reprise du cache et de débit ;
- les mêmes messages d'erreur ;
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
`bench_presets`, avec l'empreinte de configuration du Go).
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `bench` :
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
- changer de discussion ne rompt pas le bail ;
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
l'optimiseur attendent ;
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
perdu annulent le bench.
Le Go prête aussi à la façade :
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
`perf_log`.
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
conversation de la façade si elle la tient, sinon ceux du bench.
La ligne de commande (`loki bench`) reste au Go.
## Correction des tests du Go
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
et les tests la fixent à midi.
## Parité
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
sont réduits à leur taille et à leur empreinte. Le journal couvre :
- **les routes** : statut et dernière mesure sans bench, GET refusés,
annulation sans bench ;
- **les refus au lancement** : preset externe, optimisation en cours, modèle
pas prêt, génération en cours, second lancement pendant un bench ;
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
Strata ;
- **les mesures** :
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
cache, brouillon) ;
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
(celui du preset) ;
- paramètres bornés, petits paramètres, corps illisible ;
- **les échecs d'une mesure** :
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
erreur ;
- réponse sans timings ;
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
niveau appris envoyé d'emblée ;
- **budget et annulation** : budget d'une requête dépassé (profondeur
partielle, non enregistrée), annulation pendant la profondeur ;
- **après chaque étape** : la dernière mesure et les mesures par preset.
Autres tests :
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
types de cache, requête, morceaux du corpus, reprise du cache, relectures
du disque, empreinte de l'enregistrement ;
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
`Duration.String` du Go, les flottants écrits à la Go ;
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
perdu, les routes sans la façade ;
- le Go (`TestFacadeBench`) :
- les quatre routes partent à la façade, et elles seules ;
- le bail « bench » : refus, bascule de discussion, libération,
expiration ;
- le travail annexe et la trace.
## Mutations
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
l'ont été par les tests ajoutés :
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
(recalculés), le niveau de raisonnement rejoué une seule fois, le
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
tour précédent reprise, note de reprise selon le modèle hybride) ;
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
la mesure enregistrée sous le preset ACTIF ;
- le journal : un prompt juste au-delà de la prose.
Restent trois équivalentes :
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
l'annulation à son premier tour, avant que la requête ne parte ;
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
rapporte plus rien ;
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
façon.
## Écarts assumés
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
préfixe du Go (`Post "url": …`).
- Un niveau de raisonnement appris par le bench de la façade n'est pas
transmis au chat du Go, quand le Go tient la conversation (et
inversement) : la première requête paie le refus, puis la traduction est
retenue.
@@ -0,0 +1,101 @@
# Lot 8 — routes des modèles tenues par la façade
## Contexte
Les modèles `.gguf` vivaient dans le Go :
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
- les dossiers de modèles (`backend_model_dirs.go`) ;
- la suppression (`deleteModelFile`) ;
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
sans la variable rien ne change, tout part au Go).
## Ce que sert la façade
| Route | Module | Rôle |
|---|---|---|
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
autres :
- téléchargement :
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
- modèle en tranches rapatrié en entier ;
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
connexion coupée ;
- jeton envoyé aux seuls hôtes Hugging Face ;
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
dépôt), cache vidé à l'enregistrement d'un jeton ;
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
plus, il effacerait un téléchargement qu'elle mène.
## Côté Go (`facade_models.go`)
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
indisponibles : … ».
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
## Écarts assumés
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
- Erreurs de transport (réseau, TLS) au format de reqwest.
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
jamais).
- Hôte d'un lien collé en majuscules converti comme les autres.
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
livrée que pour Linux).
- `/api/models/download/status` trié par date de début.
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
valeur JSON.
## Parité
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
régénérer) :
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
`tests/downloads.rs` : lien normalisé, famille de tranches,
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
de destination ;
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
`tests/models.rs` : quantification déduite du nom, découpage
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
dossiers enregistrés compris.
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
font échouer les rejeux.
De bout en bout :
- téléchargements contre un faux Hugging Face : trois connexions sur des
plages disjointes, source sans plages, tranches dont une présente, dépôt
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
dépôt verrouillé ou absent, jeton refusé ou accepté ;
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
écrite), et laissées au Go sans la variable.
## Reste
- Essai réel sur une installation avant d'en faire le défaut.
@@ -0,0 +1,96 @@
# Lot 9 — dictée vocale tenue par la façade
## Contexte
La dictée vocale vivait dans le Go :
- `dictate_config.go` : réglages ;
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
et protocole WebSocket ;
- `web_transcribe.go` : routes.
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
variable rien ne change).
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
| Route | Rôle |
|---|---|
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
Même mécanique que le Go :
**Archive `.tar.bz2`**
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
- Entrées absolues ou qui remontent refusées, liens ignorés.
**Serveur de transcription**
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
le jeton interne dans son environnement.
- Prêt dès que sa socket répond.
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
de 2 minutes.
- Éteint après dix minutes d'inactivité.
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
**Protocole**
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
trames de 10 Kio, une trame texte en retour.
- Annotations (`[musique]`, `(rires)`) écartées.
## Côté Go (`facade_dictate.go`)
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
VRAM et l'arrêt du moteur principal par l'optimiseur.
## Écarts assumés
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
format des crates Rust.
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
## Parité
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
données tronquées, impaires, vides, et les refus ;
- les chemins d'archive ;
- les marqueurs ;
- les réglages par défaut ;
- les arguments du serveur ;
- les adresses des modèles ;
- la présence ;
- treize appels de route.
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
lieu de la moyenne) fait échouer le rejeu.
`tests/dictate.rs` couvre aussi :
- le protocole contre un faux serveur WebSocket (en-tête, découpage
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
archive corrompue) ;
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
un seul à la fois) ;
- la supervision d'un faux sherpa-onnx (script Python qui parle le
protocole) :
- transcription de bout en bout par la façade ;
- même serveur réutilisé ;
- arrêt demandé par le Go, et refusé sans le jeton ;
- réglage changé ;
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
absent.
@@ -0,0 +1,263 @@
# Lot 28 — Optimiseur tenu par la façade
## Contexte
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
plus rapides **sans rien changer à ce que le modèle calcule** : même
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
procède ainsi :
1. Il arrête le vrai moteur.
2. Il lance chaque essai sur une COPIE de la configuration :
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
`config.env` ;
- composée par « loki serve » à blanc ;
- puis lancée sur 127.0.0.1 et un port libre.
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
4. Il garde ce qui gagne au-delà du bruit.
5. Il relance le vrai moteur.
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
version rétablie si la sonde échoue.
Cinq routes :
- `POST /api/tune` lance (202) ;
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
- `POST /api/tune/cancel` annule ;
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
`preset`) ;
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
ou le moteur ont changé depuis.
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
le monde lit :
- le Go, dans tous ses refus (`tuneGuard`) ;
- « loki tune » en ligne de commande ;
- désormais la façade.
## Le lot
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
- `tune/core.rs` : la partie pure, portée à l'octet près :
- découpe et réécriture d'`EXTRA_ARGS` ;
- réglages par clé ou par drapeau ;
- essais de chaque étape selon la machine et les cases cochées ;
- garde-fous : cœur de la ligne, dénature, clés permises ;
- diff et réécriture du preset ;
- journal d'un essai ;
- score et départage ;
- `tune/lock.rs` : le verrou, **au format du Go** :
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
- écriture d'un bloc ;
- rendu neutre avant retrait ;
- ménage par déplacement-relecture ;
- `tune/job.rs` : les routes, le job, les essais et l'application.
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
celui d'un « loki tune » tué :
- l'essai orphelin est arrêté (identité complète vérifiée) ;
- une application interrompue est défaite ;
- le moteur est relancé s'il tournait.
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
le sien.
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
noté dans le verrou avant de charger quoi que ce soit.
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `tune` :
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
- changer de discussion ne rompt pas le bail ;
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
Le Go prête à la façade ce qu'il est seul à voir
(`/internal/v1/tune/context`) :
- **le refus du processus web** : un benchmark (le sien ou celui de la
façade), une génération, des jobs d'arrière-plan du mode code ;
- **l'état du vrai moteur** (`engineNeedsStop`) ;
- **la télémétrie**, d'où viennent les poids d'un tour type.
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
façade qui tient le verrou. Si la façade pilote elle-même le moteur
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
Le preset est réécrit et appliqué par les fonctions des presets
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
La ligne de commande (`loki tune`) reste au Go.
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
## Parité
`internal/loki/parity_tune_test.go` tire du Go un journal
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
- toute la partie pure ;
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
comparaison des binaires ;
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
comme une tranche nil du Go) ;
- la vérification **après application** (la configuration est-elle l'essai
retenu ?).
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
de test joue « loki serve ».
- À blanc, il écrit la ligne de commande de l'essai.
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
plus rapide que le micro-lot est petit.
Les tests couvrent :
- **une optimisation entière** :
- bail « tune » et verrou au nom de la façade ;
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
coupée ;
- quatre essais composés, lancés, mesurés, l'un retenu ;
- copies de configuration sur une adresse privée ;
- résultat enregistré par preset et rendu par `/api/tune/last` ;
- **l'application** :
- dans une copie (rien d'autre ne bouge) ;
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
- refusée sur un preset changé depuis la mesure ;
- **une sonde qui échoue** (contexte perdu) : preset et configuration
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
- **les essais refusés** : à la composition, au chargement (mémoire
insuffisante) ; la référence qui ne se compose pas ; la relance qui
échoue, dite dans l'erreur ;
- **l'annulation**, par la route et par le stop du chat ;
- **les refus d'entrée**, avec les messages du Go :
- preset externe, Strata, aucun preset actif ;
- jobs du mode code, moteur occupé, génération en cours ;
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
- **un verrou périmé** :
- l'essai orphelin arrêté seulement si son identité concorde ;
- une application interrompue défaite ;
- les routes sans la façade.
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
- prise, refus, essai noté, rendu ;
- propriétaire mort ;
- verrou à notre PID qui n'est pas l'un des nôtres ;
- illisible neuf ou vieux ;
- ménage jamais sur un verrou vivant ;
- binaire remplacé ;
- identité d'un processus ;
- arrêt du groupe.
Côté Go, `TestFacadeTune` vérifie :
- les cinq routes partent à la façade, et elles seules ;
- le bail « tune » : refus, bascule de discussion, libération ;
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
- le démarrage et l'arrêt sans le refus de l'optimiseur.
## Mutations
105 mutations du port Rust (verrou et job). Le premier passage en a tué 39.
Les survivantes l'ont été ensuite par les tests ajoutés :
- `tune/tests.rs` :
- un verrou sans PID ;
- l'identité d'un processus (démarrage noté, binaire admis) ;
- l'application planifiée : clé sur option refusée sans l'option, valeur
vide qui retire la clé ;
- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ;
- `tests/tune.rs` :
- **une optimisation entière** :
- l'essai noté dans le verrou pendant la mesure ;
- l'application refusée pendant la mesure ;
- la progression, les confirmations, le gain ;
- aucun changement de moteur signalé quand son build est inconnu ;
- la cible inconnue ;
- **l'application gardée** :
- preset qui n'est plus celui en service, API externe ;
- clé changée ailleurs pendant l'application (défaite) ;
- sauvegarde notée dans le verrou avant d'écrire ;
- **les essais** :
- écartés (ligne dénaturée, doublon, contexte perdu) ;
- muets (délai de chargement) ;
- **la mesure** :
- VRAM mesurée par un faux nvidia-smi ;
- budget atteint, profondeur commune ;
- notes et poids tirés de la télémétrie ;
- écarts de mesure (sous le seuil, nul, decode plus lent) ;
- **le moteur** :
- `/props` sans `default_generation_settings` ;
- SIDE_SLOT sur un seul slot ;
- threads comptés sur les cœurs de la sonde ;
- piloté par la façade (LOKI_ENGINE=rust) ;
- **les refus** :
- un second lancement pendant la prise du bail ;
- une application refusée sans journal ;
- hors phase d'application, un verrou périmé n'est pas défait.
Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))`
garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle,
et tuée.
Restent vingt équivalentes, ou hors de portée d'un test :
- **le verrou** :
- le fichier retiré quand son écriture échoue après sa création (disque
plein), que les tests ne provoquent pas ;
- deux re-vérifications du ménage, contre un propriétaire qui revient
entre deux lectures : courses seulement ;
- la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le
retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte
que si le retrait échoue ;
- **le suivi** :
- le résultat montré hors mesure : il est effacé au départ d'une mesure ;
- l'annulation n'est posée que pendant une mesure ;
- la borne de 6 h du budget, la durée d'un doublon, les étapes restantes :
l'estimation seulement ;
- l'annulation vérifiée entre deux essais et à la fin : l'essai en cours
l'a déjà dite (course) ;
- **la mesure** :
- VRAM nulle et nvidia-smi absent vont ensemble ;
- les candidats générés passent toujours `cfg_check` (garde contre une
génération fautive) ;
- la fin d'un essai constatée avant d'attendre : un réveil perdu, quand
l'essai meurt entre deux attentes (course) ;
- l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le
verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant
tout arrêt ;
- **l'application** :
- une valeur vide gardée dans la configuration gagnante : `cfg_check` et la
réécriture la traitent en clé absente ;
- la configuration active comparée à celle de la mesure : les empreintes
du preset et de la configuration sont prises ensemble, sur le preset dont
l'empreinte est celle de la configuration ; les deux contrôles qui
précèdent (preset inchangé, preset en service) l'impliquent ;
- le preset « non appliqué » : la configuration a changé entre le contrôle
et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre
message.
## Écarts assumés
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
sorties (comme `CombinedOutput`), mais une erreur de lecture de
`launch.json` ou d'une requête garde le texte de Rust.
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
chat du Go quand le Go tient la conversation.
@@ -0,0 +1,82 @@
# Lot 29 — Télémétrie des complétions de la façade
## Contexte
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
Pour chacune, il note :
- combien du prompt venait du cache, combien a été recalculé ;
- à quelle vitesse ;
- ce que le décodage spéculatif a gardé.
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
qui s'est intercalé entre les deux, et signale la perte à l'interface
au-delà d'un seuil tiré du preset.
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
recevait jamais la perte de cache.
## Le lot
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
le Go »), et la fusion n'en garde qu'un.
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
- après chaque complétion lue, complète ou non, numérotée dans le tour
(`perfIter`) ;
- avec les empreintes seulement si elle est complète (`perf_prefix`,
FNV-1a cumulé des messages envoyés) ;
- pour une complétion coupée par un stop, rangée incomplète.
La dernière statistique d'une complétion complète porte ce que le Go en
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
- La nature, posée par l'hôte :
- `main` pour un tour et pour les corrections du builder du mode Code ;
- `verify` pour la passe de vérification isolée ;
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
sans empreintes.
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
tolérances que `perfNum`), traduite en statistiques d'une complétion.
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
elle-même après une complétion complète de forme normale. Le Go lui demande
son verdict (`facadeTplCaps`).
`/api/perf/summary` reste au Go, qui tient l'anneau.
## Tests
- `chat/perf.rs` :
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
octet parasite, cache d'OpenAI, timings d'un autre type ;
- empreintes cumulées, FNV-1a de référence.
- La boucle du tour :
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
- dernière statistique avec la nature et la perte vues par le Go ;
- complétion coupée rangée incomplète, sans statistique finale.
- Le vrai hôte (`tests/lot6.rs`) :
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
- résumés de compaction (rangés si la réponse se lit, pas sinon).
- Le Go (`web_internal_turnperf_test.go`) :
- complétion rangée dans l'anneau ;
- perte et ce qui s'est intercalé, alerte ;
- preset externe sans alerte, complétion incomplète sans empreintes ;
- nature d'un résumé gardée, un tour par défaut ;
- corps illisible, GET refusé.
## Écarts assumés
- Les empreintes sont calculées sur le JSON des messages tel que Rust
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
complétions de la même discussion et de la même nature, toutes produites
par la façade.
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
@@ -0,0 +1,54 @@
# Lot 30 — Motifs de relance personnalisés tenus par la façade
## Contexte
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
expert peut les remplacer par la clé d'état `retry_patterns` :
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
- une regex illisible est sautée ;
- une surcharge illisible ou vide laisse les motifs embarqués.
Jusqu'ici, une surcharge posée gardait la conversation au Go
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
## Le lot
La façade lit la surcharge au démarrage de sa conversation
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
processus. Le Go ne garde plus la conversation pour cette clé.
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
`regex` sont Unicode :
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
classes ASCII ;
- dans une classe : leur équivalent dans la classe ;
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
une classe, la traduction aussi ;
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
intervalle : ils sont échappés ;
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
(`\1`), que le Go refuse, est refusée.
## Parité
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
regex (dont celles qu'il refuse). Il note leur première correspondance sur
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
position.
Autres tests :
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
texte ;
- les regex sautées, les correspondances ;
- la surcharge lue au démarrage de la conversation de la façade ;
- côté Go, la clé ne garde plus la conversation.
@@ -0,0 +1,88 @@
# Lot 31 — Slots du moteur pour le tour tenu par la façade
## Contexte
Trois clés opt-in du moteur local gardaient la conversation au Go
(`facadeChatBlocker`), faute d'être honorées par la façade :
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
(vérification, résumé sur transcription). Un cache KV à court de place en
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
voisin, pas un prompt trop long.
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
gardé sur disque à la bascule de preset, et rechargé avant la première
requête de la discussion au retour.
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
la passe de vérification du mode Code, le Go efface le slot local s'il a
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
requêtes.
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
## Le lot
### Go
- `/internal/v1/turn/slot` (`facade_slots.go`) :
- entrée : `{"conv":"…","main":true}` ;
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
appelle `engineMarkMain`.
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
### Rust
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
l'`id_slot` rendu dans le corps.
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
en service et un moteur local :
- deux fois au plus, après l'attente des reprises réseau ;
- puis l'erreur telle quelle ;
- ni compaction ni réduction.
- `GoTurnHost` :
- moteur externe : aucun slot ;
- étape d'un tour : `main` avec la discussion (donc le rechargement
`SLOT_PERSIST`) ;
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
(`side`).
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
`postSummary`. Il part sur le second slot s'il est fait sur la
transcription, sur le slot 0 s'il est fait en continuation.
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
À la fin, le Go efface le slot si le moteur a servi.
## Tests
- Go (`facade_slots_test.go`) :
- sans la clé, aucun slot et `/props` jamais lu ;
- fil de la discussion sur le 0, le reste sur le 1 ;
- second slot hors service ;
- rechargement une seule fois, pour la bonne discussion et une étape de tour
seulement ;
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
- corps illisible, GET refusé.
- La boucle du tour :
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
- sans slot, ou moteur externe : un débordement comme un autre ;
- motif `sideSlotPoolError` repris des cas du Go.
- Le vrai hôte (`tests/lot6.rs`) :
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
## Écarts assumés
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
le même cache de dix secondes.
- Le slot est demandé avant de composer la requête, et non juste avant de
l'envoyer : rien ne se passe entre les deux.
@@ -0,0 +1,97 @@
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
## Contexte
Sans la clé, une image montrée par un outil (capture, `see_image`) est
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
recalculée.
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
sous conditions :
- vision réellement active ;
- image rangée par référence et relue à l'octet près ;
- coût **mesuré** par le moteur, jamais estimé ;
- toutes les images gardées tiennent dans 10 % de la fenêtre.
Les images gardées partent avant tout résumé. La clé gardait la conversation
au Go.
## Le lot
### Go (`facade_keepimg.go`)
- `/internal/v1/turn/keepimg` rend deux choses :
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
- le budget des images gardées (`keepImgBudget`).
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
(`refRelayImage`) :
- fichier chiffré si la mémoire l'est ;
- date rafraîchie ;
- relu à l'octet près.
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
### Rust
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
- `sending` ;
- `observe` : mesure de la requête qui l'a montrée la première, partage au
prorata des octets, seuil de 16 jetons, budget ;
- `rewritten` ;
- `publishable` ;
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
Une image refusée arrête le tour pour les suivantes.
- La boucle du tour :
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
principal d'une discussion, avec la clé et la vision active ;
- `TurnHost::keep_image` range l'image par le Go ;
- l'historique réécrit en cours de tour est publié sans les relais non
gardés.
- Les images gardées partent d'abord, avant tout résumé :
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
- en cours de tour, au seuil : la compaction ne suit que si elle reste
nécessaire ;
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
si leur retrait suffit.
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
retirées si le contexte le demande, ou si elles ne doivent plus partir
(clé retirée, vision absente).
La compaction et la réduction forcée retiraient déjà les images gardées
d'abord (`chat/compact.rs`).
## Tests
- `chat/keepimg` :
- clé ;
- gardée au coût mesuré ;
- budget ;
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
- requête relancée ;
- rangement refusé ;
- partage entre deux images ;
- publication, réécriture, fin de tour, budget plein avant rangement.
- La boucle du tour :
- requête identique avec et sans la clé, image gardée par référence au
coût mesuré ;
- refus : budget, pas d'usage, rangement refusé ;
- image à l'essai en fin de tour ;
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
chaque fois sans résumé.
- Le vrai hôte (`tests/lot6.rs`) :
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
coût ;
- clé retirée : l'image part au début du tour suivant, légende et marque
gardées.
- Le Go (`facade_keepimg_test.go`) :
- état sans projecteur puis vu par le moteur, budget ;
- rangement relu à l'octet près ;
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
images ;
- corps illisible, GET refusé.
@@ -0,0 +1,112 @@
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
## Contexte
Certains recalculs sont inévitables :
- le prompt réécrit par une compaction ;
- le dernier message assistant rendu autrement au tour suivant ;
- la discussion reprise après une tâche qui a pris le slot.
Sans la clé, ils tombent sur le premier jeton du message suivant.
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
calcule plus que son propre message.
Le préchauffage ne passe jamais devant un vrai travail :
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
- un seul est en vol à la fois ;
- aucune erreur ne remonte.
La clé gardait la conversation au Go.
## Le lot
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
### Go (`facade_prewarm.go`)
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
- clé posée, moteur local ;
- ni travail annexe, ni bench, ni optimiseur en cours ;
- loin de minuit ;
- moteur en bonne santé ;
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
- `/internal/v1/prewarm/begin` :
- recharge l'état SLOT_PERSIST de la discussion ;
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
- son annulation (une autre requête part) est relayée à la façade
(`/internal/facade/v1/chat/prewarm/cancel`).
- `/internal/v1/prewarm/end` :
- rend la place ;
- note ce que le moteur a repris (`noteEnginePrompt`).
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
façade qui prolonge le préchauffage ne l'annule pas.
- Après une tâche planifiée, le déclencheur part à la façade
(`/internal/facade/v1/chat/prewarm`).
- PREWARM ne garde plus la conversation.
### Rust (`chat/prewarm.rs`)
- L'état partagé :
- le préchauffage en vol, son inscription, son annulation ;
- les capacités du dernier tour de chaque discussion (64 au plus).
- L'assemblage : les mêmes fonctions que le tour.
- Vue du Go, historique sans blocs `<context_update>`, préambule.
- Écho, messages d'envoi, corps.
- Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton.
- On s'abstient dans ces cas :
- capacités inconnues ;
- tour en cours ;
- discussion vide ;
- compaction attendue ;
- assemblage inattendu.
- Déclencheurs :
- fin de tour quand rien n'attend dans la file ;
- fin de tâche (relayée par le Go) ;
- changement de discussion ou de projet : le préchauffage en cours est
annulé ; avec `full`, la nouvelle discussion est préparée après 3 s.
- Le tour :
- `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés
et des champs qui façonnent le rendu (`prewarmOptsHash`) ;
- une requête qui ne prolonge pas le préchauffage le coupe tout de suite,
sans attendre l'aller-retour par le Go.
- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`).
## Tests
- `chat/prewarm` :
- mode (jamais pour un preset externe) ;
- empreinte des options (l'échantillonnage n'y entre pas, un champ absent
n'est pas un champ vide) ;
- rôle de la sentinelle ;
- prolongement ;
- état partagé (un seul en vol, garde, annulation, fin) ;
- capacités bornées.
- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API
externe.
- Le vrai hôte (`tests/lot6.rs`) :
- fin de tour : requête préchauffée (sentinelle, un seul jeton, même
préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de
la façade ;
- le tour suivant la prolonge et la garde (`prewarm_keep`) ;
- fin rendue avec total et cache, télémétrie ;
- refus du Go : rien ne part ;
- annulation relayée : connexion coupée, place rendue.
- Le Go (`facade_prewarm_test.go`) :
- contrôle : clé, slots, minuit, travail annexe, moteur muet ;
- inscription et deuxième préchauffage refusé ;
- gardé par le tour qui le prolonge, annulé et relayé sinon ;
- fin unique ;
- requête déjà en vol ;
- déclencheur après tâche relayé.
## Écarts assumés
- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en
cours) annule celui qui part, quel que soit son numéro : il n'y en a
qu'un.
@@ -0,0 +1,92 @@
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
## Contexte
Le contexte du projet part en tête du premier message utilisateur :
- description ;
- index mémoire ;
- trackers ;
- consignes du dépôt ;
- avec la clé, la date et le dossier de travail.
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
recalculé.
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
discussion. Les changements arrivent en `<context_update>` en tête du
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
n'est rafraîchi que quand le début du prompt change de toute façon :
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
jour accumulées.
C'était le dernier réglage qui gardait la conversation au Go.
## Le lot
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
lit le contexte du projet : elle reste au Go.
### Go (`facade_projsnap.go`)
- `/internal/v1/turn/view`, avec la clé et l'agent :
- reçoit l'instantané de la discussion (`snap`) ;
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
s'il faut rafraîchir) et le préambule sans date ni dossier
(`envInCtx`) ;
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
l'instantané à ranger, la clé et l'empreinte du système du tour.
- `dry` (préchauffage) : la même décision, rien à ranger.
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
devient (`projSnapTurn.refresh`).
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
décision prise une fois par processus disparaissent. Le Go la remet dès
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
### Rust
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
du mode Code :
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
l'historique ;
- mise à jour : posée en tête du dernier message utilisateur
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
annoncé ne bouge que si elle a trouvé où se poser ;
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
comme avant.
- `TurnHost::project_refresh` : après une compaction ou une réduction en
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
mises à jour sont retirées. L'instantané posé devient celui de la
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
touche pas au début du prompt : pas de rafraîchissement.
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
## Tests
- Go (`facade_projsnap_test.go`) :
- la séquence assemblée par la façade égale celle de `turnView`, outils et
instantané compris ;
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
état annoncé suivi ;
- sans changement : rien à poser ;
- `dry` sans rien à ranger ;
- clé changée : rafraîchi, bloc vivant en préchauffage ;
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
tour.
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
pourquoi.
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
projet remplacé à sa place (cas du Go).
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
pas après un retrait d'images gardées.
- Rust, vrai hôte (`tests/lot6.rs`) :
- instantané rangé ;
- mise à jour posée dans la requête et l'historique (marque retirée à
l'envoi) ;
- rafraîchissement qui retire les mises à jour ;
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
- préchauffage en `dry`.
@@ -0,0 +1,48 @@
# Lot 35 — Routes de base par le tunnel du relais
## Contexte
La façade sert toujours neuf routes, sans variable (`api.rs`) :
- `/api/ping` ;
- `/api/status` ;
- `/api/config` ;
- `/api/engine/cmdline` ;
- `/api/model/layers` ;
- `/api/paths` ;
- `/api/ram` ;
- `/api/vram` ;
- `/api/service/log`.
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
implémentations à garder d'accord.
## Le lot
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
présents), sans variable. Façade muette : 502 « façade indisponible ».
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
comme pour les routes des lots ; la version du Go est lue quand même pour
`/api/ping`.
## Tests
- Go (`facade_core_test.go`) :
- les neuf routes relayées avec le jeton ;
- une route du Go gardée ;
- façade muette ;
- sans façade, servies ici.
- Rust (`tests/lot2.rs`) :
- relayée par le Go avec un hôte distant et une clé posée : servie par la
façade, pas renvoyée au Go ;
- sans jeton, ou avec un faux : refusée.
## Reste à faire
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
handlers sont gardés pour `LOKI_FACADE=0`.
@@ -0,0 +1,56 @@
# Lot 36 — Appels d'outils MCP sans détour par le Go
## Contexte
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
faisait encore exécuter un appel `mcp__*` par le Go
(`/internal/v1/tool/exec`).
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
allers-retours locaux pour revenir au même pool.
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
- ni capture ;
- ni aperçu du navigateur ;
- ni image relayée.
Seul le texte revient.
## Le lot
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
fois dans `main.rs`).
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
directement. Le résultat est celui de la route interne :
- texte aplati et coupé ;
- « [l'outil a renvoyé une erreur] » ;
- « [ok] (aucune sortie) » ;
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
qui l'exécute avec son propre pool.
Le Go garde la route interne pour les tours qu'il exécute encore :
- sous-agents ;
- tâches IA ;
- discussion rendue au Go.
## Tests
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
serveur MCP stdio. Un tour de la façade appelle :
- un outil qui répond ;
- un outil dont le serveur meurt.
Le test vérifie :
- aucun appel au Go ;
- le résultat de chaque outil tel que le modèle le reçoit ;
- l'erreur après une reconnexion ;
- la version du Go annoncée à cette reconnexion.
@@ -0,0 +1,82 @@
# Lot 37 — Outils task_* de l'agent en Rust
## Contexte
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
- les routes ;
- le planificateur ;
- les tâches « script seul ».
Les quatre outils par lesquels le modèle gère lui-même ses tâches
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
- `task_list` ;
- `task_create` ;
- `task_update` ;
- `task_delete`.
## Le lot
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
près :
- **`task_list`** :
- liste du projet actif, aperçu d'une ligne par tâche ;
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
octet comme à l'encodage JSON du Go ;
- avec un id, la tâche seule et son compte-rendu complet ;
- une tâche d'un autre projet est « introuvable » ;
- **`task_create`** :
- tâche unique « dans N minutes » (durée tronquée comme
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
- fréquence validée ;
- script vérifié dans le dossier protégé ;
- tâche rattachée au projet actif ;
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
un ;
- **`task_update`** : modification partielle ; la prochaine exécution est
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
n'en a pas ;
- **`task_delete`**.
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
au Go comme avant.
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
fixer.
## Parité
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
- l'heure, le fuseau, le projet actif, les projets connus ;
- les scripts présents ;
- une tâche posée telle quelle en base ;
- les arguments tels que le modèle les envoie ;
- le résultat tel qu'il le reçoit ;
- les tâches en base après coup.
Le journal couvre :
- l'heure du trou du changement d'heure ;
- une date passée ou impossible ;
- des arguments d'un autre type ;
- un script absent, hostile ou dossier ;
- le compte-rendu coupé au milieu d'un caractère ;
- le fuseau d'une tâche existante.
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
façade qui crée une tâche. Il vérifie :
- rien ne passe par le Go ;
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,77 @@
# Lot 38 — L'outil criteria du mode Code dans la façade
## Contexte
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
l'outil `criteria` :
- les poser ;
- les marquer faits ;
- les lister ;
- les effacer.
La passe de vérification les marque ensuite `passed` ou `failed`.
Depuis le lot 19, la façade sert déjà les critères de l'écran
(`/api/chat/criteria`, `chat/crit:<id>` scellés). La passe de vérification
les relit depuis le lot 7. Mais l'outil lui-même partait au Go
(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta
(`critNotify` → `/internal/facade/v1/delta`).
## Le lot
`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près :
- **list** : action vide ou d'un autre type comprise ;
- **add** :
- textes vides ou d'un autre type ignorés ;
- ids repris après le plus grand ;
- vingt critères au plus ;
- **set** :
- id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ;
- statut libre, note rognée ;
- `passed` refusé hors de la passe de vérification ;
- **clear**.
Les règles de rangement et d'affichage :
- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme
dans le Go ;
- une liste rangée illisible vaut « aucun critère » ;
- la note vide n'est pas écrite (`omitempty`).
`GoTurnHost::exec_tool` l'exécute pour la discussion du tour
(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à
pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta
`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte :
- le journal de la discussion pour un tour ;
- celui de la conversation pour une passe de vérification.
Écart assumé : le Go rangeait les critères sous la discussion **active**
au moment de l'appel (`convEnsureActive`). La façade les range sous celle du
tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant
la génération, et c'est alors la façade qui a raison.
`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant
de l'encoder, ce qu'un port ne rendrait pas octet pour octet.
## Parité
`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels
(`tests/parity_tools/criteria.json`). Chaque étape porte :
- les arguments, builder ou vérification ;
- le résultat ;
- les critères rangés ensuite.
`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il
vérifie aussi :
- le rangement scellé ;
- la liste poussée à l'écran à chaque changement, et seulement alors.
`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai
contrat. La passe de vérification marque le critère `passed` dans la
façade : rien ne passe par le Go, le critère est rangé scellé, l'écran
reçoit la liste.
@@ -0,0 +1,92 @@
# Lot 39 — Anneau de télémétrie tenu par la façade
## Contexte
Chaque complétion qui passe par le moteur est rangée dans un anneau
(`perf_log.go`). L'anneau :
- calcule la perte de cache d'une complétion contre la précédente de la même
discussion et de la même nature ;
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
- signale une perte au-delà du seuil du preset (stats finales) ;
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
anneau tenu ailleurs.
## Le lot
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
plus pour nommer ce qui s'est intercalé ;
- la perte : seulement si la complétion prolonge strictement la précédente
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
- la ligne `[perf]` (LOKI_PERF_LOG) ;
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
- `-cms` d'EXTRA_ARGS ;
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
- `CKPT_MIN_STEP` ;
- 2048 sur un modèle hybride ;
- plus le micro-batch ;
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
dernier verdict de la sonde de gabarit).
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
résumés de compaction et le préchauffage de la façade y rangent leurs
complétions directement. Les marques (perte, ce qui s'est intercalé,
alerte) partent sur les stats finales comme avant.
La façade sert :
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
client externe de `/v1`), rendue telle que rangée ;
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
l'optimiseur ;
- `GET /api/perf/summary`.
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
prend le relais. Sans façade, ou la conversation rendue au Go
(`LOKI_CHAT=go`), rien ne change.
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
## Parité
`internal/loki/parity_perf_test.go` tire du Go un journal
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
les empreintes de leurs messages ; elles couvrent :
- prolongement ;
- perte après un sous-agent et un client externe ;
- cache inconnu ;
- divergence ;
- autre discussion ;
- complétion coupée ;
- compaction, bench, vérification.
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
l'heure posée.
`tests/lot6.rs` :
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
la perte est mesurée par la façade et signalée dans les stats finales ;
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
servi par la façade ;
- les tests du lot 29 lisent désormais l'anneau de la façade.
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
- complétion confiée à la façade ;
- anneau relu ;
- repli sur l'anneau du Go quand la façade est en panne ;
- route remise.
@@ -0,0 +1,87 @@
# Lot 40 — Outils mémoire de l'agent dans la façade
## Contexte
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
- pages ;
- index `MEMORY.md` ;
- trackers ;
- mode mémoire.
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
(`/internal/v1/tool/exec`) :
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
- `tracker`.
Deux processus écrivaient donc les mêmes pages et le même index.
## Le lot
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
routes de la mémoire :
- pages scellées selon le chiffrement ;
- `.bak` de la version précédente ;
- écriture vérifiée ;
- index tenu par le code.
Chaque outil :
- **`mem_search`** : classement par couverture des termes puis par
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
coupé) ; limite 8 par défaut, 30 au plus ;
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
nom du projet), diff d'ajout ;
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
- **`tracker`** :
- vue d'ensemble ;
- squelette (années, année en cours par mois) ;
- une année par mois ;
- points (60 plus récents au plus) ;
- ajout, modification, suppression d'un point (jamais d'un tracker
entier) ;
- arguments d'un autre type rendus comme `str` du Go.
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
comme avant.
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
`trackerNow`, que le journal fixe ; rien ne change en service.
## Parité
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
étape porte :
- les arguments ;
- le libellé du tour ;
- le résultat, le diff ;
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
- les trackers en base.
Il couvre :
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
- lecture : plage, au-delà, page absente, nom invalide ;
- ajout : existant, nom invalide, majuscules ;
- modification : rejouée, déjà en place, absente, en double, vide ;
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
inconnus.
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
façade qui crée une page. Il vérifie :
- rien ne passe par le Go ;
- la page est rangée dans le projet actif, l'index tenu ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,133 @@
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
## Contexte
Depuis le lot 22, la façade tient les backends llama.cpp quand
`LOKI_BACKENDS=rust` :
- statut, vérification git ;
- binaires précompilés ;
- bascule fast/opt ;
- backends custom ;
- le job des moteurs.
La compilation depuis les sources restait au Go. Il en rapportait chaque
étape au job de la façade par l'API interne
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
- `POST /api/llamacpp/install` `{force}` ;
- `POST /api/llamacpp/update` `{clean}` ;
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
## Le lot
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
- `backend_build.go` : plan, CMake, étapes suivies ;
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
(`web_llamacpp.go`) ;
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
(`backend_llamacpp.go`) ;
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
Le port couvre :
- **le plan complet** (`detectBuildPlan`) :
- drapeaux Release, natif, UI de llama-server coupée ;
- Metal sous macOS ;
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
compilateur explicites hors `/usr`, architectures lues par
`nvidia-smi` ;
- HIP, puis Vulkan ;
- **CMake** :
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
`build.old` ;
- nvcc dans le PATH et `CUDACXX` ;
- `configure.log` et `build.log` dans le dépôt ;
- erreurs « configuration CMake échouée » et « compilation échouée » ;
- **les étapes suivies** :
- sorties standard et d'erreur dans un même tube, ordre gardé ;
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
les 10 s, reste sans fin de ligne perdu) ;
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
plus de 1 Mio qui arrête la lecture) ;
- octets invalides en U+FFFD un à un ;
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
programme introuvable) ;
- **les outils** :
- git et cmake, puis cc, c++, make ;
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
(sudo hors root) ;
- sinon le message du Go ;
- **les jobs** :
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
en mise à jour ; `force` : supprimé puis recloné ;
- mise à jour : fetch, pull au besoin, commits avant et après ;
« déjà à jour » sans recompiler ; service arrêté le temps du build et
relancé même en échec ;
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
toucher à BIN.
Les routes répondent comme le Go :
- moteur fourni par l'image : 409 et le message du Go ;
- custom sans URL : 400 « URL du dépôt requise » ;
- optimisation en cours : 409 ;
- job déjà lancé : 409 ;
- sinon 200 `{"ok":true}` et le job tourne.
Le service moteur est celui de la façade si elle le pilote, sinon celui du
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
façade :
- générateur Visual Studio ;
- intégration CUDA de MSBuild ;
- `CUDA_PATH`.
Les messages du terminal du Go (outils manquants, dépendance connue absente
après un configure en échec) vont à la sortie d'erreur de la façade.
## Parité
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
porte :
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
- BIN ;
- les dépôts qui ont un `build.old`.
Il couvre :
- mise à jour sans dépôt ;
- installation, puis installation sur un dépôt présent et installation
forcée ;
- déjà à jour, nouveau commit, recompilation propre ;
- build en échec (fin des journaux) ;
- cmake absent, compilateur absent ;
- clone en échec ;
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
existant sur un ref (URL `git@`).
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
Les tests unitaires de `build.rs` couvrent :
- la barre de progression (`TestSinkWriterProgression`) ;
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
démesurée) ;
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
- le cache CMake déplacé ;
- noms et URL des backends custom ;
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
architectures, HIP, Vulkan) ;
- les erreurs des commandes (code de sortie, signal, programme absent,
journal écrit).
@@ -0,0 +1,121 @@
# Lot 42 — Outils web de l'agent dans la façade
## Contexte
La façade tient la conversation par défaut. Les outils web de ses tours
partaient pourtant au Go (`/internal/v1/tool/exec`) :
- `web_search` ;
- `web_images` ;
- `web_open`, `web_read`, `web_grep`.
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
faisaient chacun un aller-retour.
## Le lot
`src/webtools.rs` porte les outils. Sources Go :
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
DuckDuckGo ;
- `chat_web_images.go` : recherche d'images.
Ce qui est porté :
- **le cache** :
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
toutes options confondues ;
- 10 minutes ;
- **Crawl4AI** :
- corps envoyé au Go près (clés triées, échappement HTML) ;
- clé d'accès en `Bearer` ;
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
(types faux, `null`, casse des clés) ;
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
injoignable (texte de l'erreur du client Go) ;
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
réduits ; plan des titres ;
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
liens DuckDuckGo écartés, défi anti-bot ;
- **les images** :
- jeton `vqd` de la page, puis `i.js` ;
- mêmes en-têtes ;
- cookies gardés par hôte, comme le jar du client Go ;
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
texte du Go) ;
- **`web_grep`** :
- regex du Go (`(?i)`, classes ASCII) ;
- erreurs au texte de `regexp` pour les fautes courantes ;
- **le plafond** de 8000 caractères ;
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
Ces deux étapes restent au Go, derrière deux appels sans état :
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
(`goFetchMarkdown`) ;
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
(`goSearch`).
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
Le traducteur des regex du Go (`go_regex`) refuse désormais :
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
- un compte au-delà de 1000 ou inversé.
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
motifs `retry_patterns` en profitent.
Côté Go, pour le journal :
- `webNow` : l'horloge du cache ;
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
## Écart connu
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
effet sur DuckDuckGo.
## Parité
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
appels (`tests/parity_web/outils.json`). Chaque appel porte :
- le résultat ;
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
- les pages et recherches demandées au moteur intégré.
Il couvre :
- recherche : limites, anti-bot, aucun résultat, panne ;
- lecture : plages, bornes, au-delà, page jamais ouverte ;
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
regex invalides ;
- cache : réouverture, forcée, options, la plus récente, péremption ;
- GitHub ;
- les réponses de Crawl4AI ;
- serveur injoignable, sans serveur, sans clé ;
- moteur intégré : ouverture, texte brut, page absente, recherche ;
- images : doublons, cookie gardé, jeton absent, pannes, réponses
illisibles ou mal typées, `null`.
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
moteur intégré a tiré des pages.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
une page sans passer par `/tool/exec` ;
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
regex, réponses de Crawl4AI.
@@ -0,0 +1,82 @@
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
## Contexte
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
gardait les processus. C'étaient les derniers outils shell du tour encore au
Go, `bash` étant porté depuis le lot 7.
## Le lot
`src/tools/jobs.rs` porte `code_jobs.go`.
**`bash_bg`** :
- refus avant tout lancement :
- commande vide ;
- commande dangereuse (même politique que `bash`) ;
- optimisation en cours (`/internal/v1/tune/guard`) ;
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
contexte des outils du Go) ;
- numéros `job1`, `job2`… ;
- shell de la plateforme, sans le jeton interne dans l'environnement ;
- le processus survit au tour ;
- code de sortie du Go : -1 pour un processus tué.
**`bash_tail`** :
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
`int(float64)` ;
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
seconde ;
- sortie vide signalée ;
- fin gardée en caractères ;
- octets invalides en U+FFFD un à un ;
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
encore dire « en cours ».
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
`ChatShared.jobs` (les jobs du processus) sert les tours :
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
`bash_bg` passe au Go comme avant.
Les jobs d'une discussion supprimée s'arrêtent avec elle
(`GoSessionHooks::dropped`, `stopConvJobs`).
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
les siens seulement.
## Parité
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
et durées masqués) et les journaux présents. Il couvre :
- commande vide, refusée ;
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
- arrêt d'un job fini ;
- job inconnu, sans id ;
- sortie en erreur sans rien d'écrit ;
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
- quarante lignes par défaut, deux cents au plus ;
- sortie démesurée en caractères multi-octets ;
- octets invalides.
`tests/jobs.rs` le rejoue contre le port.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
lance un job dans le dossier de la discussion sans passer par le Go ;
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
comprise ;
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
impossible.
@@ -0,0 +1,138 @@
# Lot 44 — Navigateur piloté dans la façade
## Contexte
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
protocole DevTools (CDP). L'agent l'utilise ainsi :
- il ouvre une page et lit les éléments interactifs numérotés ;
- il clique, tape, défile par numéro ;
- avec la vision, il voit une capture quadrillée et clique en
coordonnées.
La façade tient la conversation par défaut, mais la session Chrome, les
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
au Go (`/internal/v1/tool/exec`).
## Le lot
`src/browser/` porte quatre fichiers Go :
- `computer_cdp.go` : client CDP, lancement, scripts ;
- `computer_use.go` : les neuf outils ;
- `browser_grid.go` : la grille de la capture ;
- `cu_autoshot.go` : les aperçus.
Ce qui est porté :
- **le lancement** :
- binaire imposé, celui de Playwright ou du système ;
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
- viewport fixe 1280×800 ;
- script qui garde les nouveaux onglets dans l'onglet courant ;
- **la session** :
- une seule, lancée à la demande ;
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
plus ;
- commandes multiplexées par numéro, 30 s au plus ;
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
après un chargement ;
- **les scripts** injectés, copiés du Go à l'octet près (le journal
compare leurs empreintes) :
- éléments actionnables et visibles ;
- shadow DOM ;
- nom d'un champ par son libellé, sans révéler un mot de passe ;
- recherche non destructive ;
- liste déroulante native réglée par son option ;
- **les outils**, mêmes textes :
- snapshot dédoublonné ;
- indices de défilement ;
- erreurs de navigation (`errorText`, page `chrome-error://`,
`ERR_ABORTED` bénin) ;
- `back` et `forward` ;
- touches nommées ;
- plafond de 8000 caractères ;
- **la capture** (`browser_screenshot`, vision active) :
- PNG propre rangé dans le dossier de travail
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
à 1280×800 elle est sous la taille maximale et part telle quelle ;
- relayée au modèle comme `see_image` ;
- **les aperçus** :
- après une action qui change la page, jamais après une erreur ;
- JPEG en mémoire, anneau de huit ;
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
`ChatShared.browser` porte le navigateur du processus ;
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
ses propres appels (`facade_browser.go`) :
- outils des sous-agents et des tâches IA ;
- aperçus ;
- fermeture (interrupteur coupé, ligne de commande).
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
rend le cas impossible).
## Parité
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
- un script annonce le port d'un faux serveur CDP ;
- le faux CDP répond selon le scénario de chaque étape et note chaque
commande.
Chaque étape porte :
- le résultat ;
- les commandes CDP (méthode, paramètres, scripts résumés par leur
empreinte) ;
- pour une capture, ses dimensions et dix pixels de la grille ;
- les captures rangées.
Les arguments de lancement sont relevés aussi. Le journal couvre :
- ouverture, snapshot inchangé, réouverture complète ;
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
- défilements ;
- clics par numéro, introuvable, refusé, en coordonnées ;
- saisies libre, dans un champ, liste déroulante, option absente, champ
introuvable ;
- touches ;
- recherches ;
- navigations en échec, page d'erreur, interruption bénigne, `back`,
`forward`, commande refusée ;
- captures sans vision, quadrillée, refusée ;
- sortie plafonnée.
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
de l'anneau) et la fermeture (profil retiré).
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
en échec.
D'autres tests complètent :
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
une page sans passer par le Go, avec aperçu ; l'API du Go et
`/api/chat/cushot` par une façade complète ;
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
fermeture à la façade, et se replie sur son navigateur si elle est en
panne ;
- tests unitaires : outils, nombres au format du Go, arguments, grille,
profils, lancement impossible.
@@ -0,0 +1,116 @@
# Lot 45 — Outils d'image de l'agent dans la façade
## Contexte
Deux outils de l'agent montrent une image au modèle :
- `see_image` charge un fichier image du disque dans la vision du
modèle ;
- `web_screenshot` photographie une page rendue par Playwright, la range
dans le dossier de la discussion et donne à l'agent la ligne markdown
qui la montre à l'utilisateur.
La façade tient la conversation par défaut, mais ces deux outils passaient
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
relayer dans `vision_img`.
## Le lot
`src/imagetools.rs` porte trois fichiers Go :
- `chat_vision_tool.go` (`see_image`) ;
- `chat_screenshot.go` (`web_screenshot`) ;
- `prepareImageForModel` (`web_upload_orient.go`).
Ce qui est porté :
- **la préparation de l'image**, la même que pour les pièces jointes :
- orientation EXIF d'une JPEG redressée dans les pixels ;
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
- rien à faire, ou image que la bibliothèque standard du Go ne décode
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
- **la sonde du moteur** (`engineSeesImages`) :
- preset externe : sa case « vision » ;
- sinon `GET /props` du moteur local, authentifié par la clé de
complétion, gardé 10 s ;
- appelée en dernier, après toutes les vérifications du fichier ;
- **`see_image`**, mêmes textes :
- vision coupée, poste distant, chemin manquant ;
- format non reconnu, fichier introuvable, dossier ;
- trop lourde (12 Mo au plus) ;
- moteur aveugle, fichier vide ;
- chemin relatif au dossier de travail de l'agent, ou absolu ;
- **`web_screenshot`**, mêmes textes :
- Playwright absent, url manquante ou invalide ;
- internet coupé : seule une adresse locale passe (`localhost`,
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
le Go) ;
- mêmes arguments de Playwright (viewport, page entière, attente,
90 s au plus) ;
- erreur résumée par ses trois dernières lignes non vides ;
- aucune image ou image vide refusée ;
- capture rangée dans `captures/` du dossier de la discussion, nommée
par l'hôte et l'heure locale ;
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
- relayée au modèle (« Voici la capture demandée. ») seulement si la
vision est active et que le moteur voit les images.
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
exécute les deux outils :
- `see_image` prend le dossier de travail et le poste visé du contexte
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
- `web_screenshot` range ses captures dans le dossier de la discussion
principale, sous le dossier de travail de l'agent, et les montre par
`ToolOutcome.image`.
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
garde les siens pour ses sous-agents et ses tâches IA.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
## Parité
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
un faux Playwright (script qui note ses arguments et produit l'image, rien,
un fichier vide ou une erreur).
Chaque étape porte :
- le résultat, avec le texte relayé au modèle ;
- l'image relayée :
- identique à un fichier : son nom ;
- réencodée : son type et ses dimensions ;
- en PNG : cinq pixels ;
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
- le dossier des captures.
Le journal couvre :
- `see_image` :
- vision coupée ;
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
- moteur aveugle, fichier vide ;
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
grandes images réduites en largeur et en hauteur, sous-dossier,
chemin absolu ;
- `web_screenshot` :
- url manquante ou invalide, internet coupé ;
- capture locale sans vision, capture vue par le modèle ;
- page entière réduite ;
- vision mais moteur aveugle ;
- Playwright en échec, image vide, aucune image ;
- ménage des captures.
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
complètent :
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
appelle les deux outils sans passer par le Go ;
- tests unitaires : types d'image, noms de capture, adresses locales,
dernières lignes, préparation, ménage, refus avant Playwright.
@@ -0,0 +1,97 @@
# Lot 46 — Outils recall et recall_search dans la façade
## Contexte
La compaction résume les vieux tours d'une discussion. Avant de résumer un
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
- `recall(id)` rend le bloc exact ;
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
sorti du résumé courant.
La façade compacte elle-même et archive par le Go
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
base, que seul le Go tient. Les deux outils passaient encore par
`/internal/v1/tool/exec`.
## Le lot
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
(`chat_recall.go`) :
- **l'id tolérant** (`recallIDToSeq`) :
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
- un id mal formé ne lit pas l'archive ;
- **les mots-clés** (`recallTokens`) :
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
donne `i`, `Σ` donne `σ`) ;
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
- deux runes au moins ;
- **le score** :
- un point par terme présent dans le contenu, deux dans le libellé ;
- à égalité, le plus récent d'abord ;
- 5 résultats par défaut, 20 au plus, limite tronquée comme
`int(float64)` ;
- **les textes**, mêmes que le Go :
- id ou requête manquants ;
- bloc introuvable (id cité par `%q`) ;
- bloc rappelé verbatim ;
- liste des résultats avec un extrait d'une ligne de 160 runes ;
- aucun résultat.
L'archive reste au Go, qui la sert à la façade :
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
absent ou chiffré sous une mémoire verrouillée ;
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
croissante.
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
(`recallGetSeq`, `recallAll`).
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
Si le Go ne répond pas, l'appel passe par lui.
## Parité
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
- accents et casse Unicode ;
- balises et entités ;
- bloc vide ;
- premières lignes longues ;
- vingt-cinq blocs semblables.
Il tire ensuite du Go un journal de 41 appels
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
sert :
- `recall` :
- id absent, non texte, blanc ;
- variantes de préfixe et d'espaces ;
- zéros de tête ;
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
- guillemets dans l'id ;
- `recall_search` :
- requête absente, non texte, blanche ;
- mots trop courts ;
- casse, accents, `İ`, grec hors des mots ;
- souligné coupé ;
- extraits bornés ;
- limites : une, tronquée, négative, par défaut, au plus, en texte
ignorée ;
- aucun résultat ;
- ponctuation.
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
aussi le repli quand le Go ne répond pas.
D'autres tests complètent :
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
et cherche sans passer par `/tool/exec` ;
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
illisible, méthode refusée, ordre) ;
- tests unitaires : ids, mots-clés, score, extraits, textes.
@@ -0,0 +1,115 @@
# Lot 47 — Outil subagent du mode Code dans la façade
## Contexte
En mode Code, le builder peut déléguer une question bornée à un rôle en
lecture seule :
- `explorer` cartographie le code ;
- `code-reviewer` relit un changement ;
- `planner` découpe un travail et pose les critères d'acceptation.
Le rôle travaille dans son propre contexte : le prompt du rôle, le
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
rangée ni affichée. Seul son texte final revient au builder, comme
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
évite de payer dix lectures de fichiers dans le fil principal.
La façade tient le tour du builder et sa passe de vérification, mais
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
tourner un `runChat` à lui.
## Le lot
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
même ordre, avec les mêmes textes :
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
- tâche vide ;
- appel depuis un tour qui n'est ni le fil principal ni le builder
(garde-fou anti-récursion).
Le contexte du rôle vient du Go
(`POST /internal/v1/turn/subagent {role}`), qui rend :
- le prompt du rôle suivi du briefing machine ;
- les outils du rôle ;
- ses capacités (agent, Code, mémoire coupée).
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
vérification :
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
sont ceux du tour qui délègue (`SubTurn`) ;
- la température est 0,6 (le TEMP du preset l'emporte) ;
- la tâche porte le cadrage du rôle :
- planner : critères et étapes, sans demander confirmation ;
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
effacé à la fin pour que le builder reparte du cache RAM ;
- la télémétrie le compte à part (`subagent`) ;
- les critères posés par le planner s'affichent comme ceux du fil.
Le rapport se construit ainsi :
- seul le texte écrit après le dernier outil compte ;
- il est rogné de ses blancs, puis préfixé du rôle ;
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
- vide : « n'a rien répondu » ;
- erreur du tour : « sous-agent <rôle> : <erreur> ».
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
imbriqué ne se contient pas lui-même.
Le tour du builder et ses relances par la boucle du contrat prêtent leur
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
## Parité
`internal/loki/parity_subagent_test.go` tire du Go un journal
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
vues des trois rôles sont rangées avec lui.
Pour chaque tour délégué, le journal garde chaque requête du rôle :
- système identique à la vue ;
- message de la tâche cadrée ;
- température ;
- outils ;
- nombre de messages.
Il couvre :
- les refus :
- rôle inconnu, `verifier`, rôle absent ;
- tâche vide ou absente ;
- récursion depuis un rôle ou depuis la vérification ;
- les tours :
- réponse directe rognée ;
- rapport après deux outils, narration écartée ;
- texte vide relancé puis remplacé, comme le fait le tour ;
- rien après l'outil ;
- rapport tronqué ;
- cadrage du planner ;
- moteur qui refuse.
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
- les refus par `precheck` ;
- les tours par un tour complet de la façade qui délègue, contre le même
script.
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
annexe ouvert puis fermé.
D'autres tests complètent :
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
- tests unitaires : refus et leur ordre, rapports, cadrages.
+6 -1
View File
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
}
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
// chars, elide the middle. Empty in → empty out.
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
func maskAPIKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 2 {
return "…"
}
if len(k) <= 13 {
return "…" + k[len(k)-2:]
}
+21 -15
View File
@@ -29,7 +29,8 @@ import (
"time"
)
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
type ghAsset struct {
Name string `json:"name"`
@@ -41,18 +42,25 @@ func prebuiltDir() string {
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
}
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
// alors que DEUX champs, et le format est le second.
func prebuiltVersionFields() []string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return nil
}
return strings.Fields(strings.TrimSpace(string(b)))
}
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
// (cudaVer vide hors CUDA Windows).
func prebuiltVersion() (tag, cudaVer string) {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return "", ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
f := prebuiltVersionFields()
if len(f) > 0 {
tag = f[0]
}
if len(f) > 1 {
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
cudaVer = f[1]
}
return
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
// sera refaite au lieu d'être déclarée « déjà à jour ».
const prebuiltFormat = "fmt2"
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
func prebuiltVersionFormat() string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
if len(f) > 2 {
return f[2]
f := prebuiltVersionFields()
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
return f[len(f)-1]
}
return ""
}
+33 -15
View File
@@ -52,13 +52,23 @@ import (
// autre paquet est remplacé au lancement (strataEnsureEngine).
const (
strataVersion = "1.0"
strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
strataEngineLocal = "strata-linux-x64-cuda12.zip"
)
// strataReleaseBase : la release figée. Une variable pour que les tests la
// pointent sur un faux serveur.
var strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
// meminfoPath, strataDiskFree : la RAM et la place libre lues sur la machine,
// remplaçables par les tests (journal de parité de Strata).
var (
meminfoPath = "/proc/meminfo"
strataDiskFree = diskFree
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
var strataAssetSHA = map[string]string{
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
@@ -154,7 +164,7 @@ const (
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
func memAvailableGB() float64 {
b, err := os.ReadFile("/proc/meminfo")
b, err := os.ReadFile(meminfoPath)
if err != nil {
return totalRAMGB()
}
@@ -286,7 +296,7 @@ func strataDetect() strataEnv {
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := diskFree(strataHome()); f > 0 {
if f := strataDiskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -1380,25 +1390,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// « Actif » se décide AVANT l'écriture (comme SavePresetApplying) : c'est
// l'ancien contenu qui porte l'empreinte de la configuration courante. Lu
// après, un réglage changé faisait passer le preset en service pour
// inactif, et rien n'était appliqué.
active := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
active = true
}
}
}
path := filepath.Join(presetsDir(), req.Preset+".env")
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
if active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted})
}
+1 -1
View File
@@ -552,7 +552,7 @@ func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResu
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
Weights: tuneWeightsFrom(perfLog.snapshot())}
Weights: tuneWeightsFrom(perfSnapshot())}
// Référence : la ligne composée telle quelle, la sonde de la machine.
tr.setPhase("référence : composition de la ligne de commande")
+10 -3
View File
@@ -347,6 +347,9 @@ var (
const pageCacheTTL = 10 * time.Minute
// webNow : l'horloge du cache des pages (fixée par le journal de parité).
var webNow = time.Now
type fetchOptions struct {
force bool
actions []string
@@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry {
var best *cacheEntry
for k, v := range pageCache {
if k == u || strings.HasPrefix(k, u+"#") {
if time.Since(v.fetchedAt) > pageCacheTTL {
if webNow().Sub(v.fetchedAt) > pageCacheTTL {
continue
}
if best == nil || v.fetchedAt.After(best.fetchedAt) {
@@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
pageCacheMu.Lock()
cached := pageCache[key]
pageCacheMu.Unlock()
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL {
return cached, nil
}
@@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
if strings.TrimSpace(md) == "" {
return nil, fmt.Errorf("page vide")
}
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()}
pageCacheMu.Lock()
pageCache[key] = entry
pageCacheMu.Unlock()
@@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string {
})
}
// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré
// (goSearch ; un faux pour les tests).
var ddgHTMLBase = "https://html.duckduckgo.com"
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
func decodeUddg(raw string) string {
s := raw
+4
View File
@@ -15,6 +15,10 @@ import (
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
// mem_add, mem_edit.
//
// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé
// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ;
// journal de parité dans parity_mem_tools_test.go.
type MemPage struct {
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
+9 -1
View File
@@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool {
// Points d'accroche remplaçables par les tests.
var (
// prewarmNow : l'heure du garde-fou de minuit.
prewarmNow = time.Now
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
prewarmAsync = true
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
@@ -265,10 +267,16 @@ var (
)
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
// Quand la façade tient la conversation, le déclencheur lui part
// (facade_prewarm.go) : c'est elle qui a l'historique.
func (c *Conversation) prewarmKick(why string) {
if prewarmMode(ReadConfig()) == "" {
return
}
if facadeChat() {
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
return
}
if prewarmAsync {
go c.prewarm(why)
return
@@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) {
return
}
}
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) {
return
}
if !healthCheck() {
+5
View File
@@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string {
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
// préchauffage synchrone. keys : réglages posés.
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
// Le préchauffage se tait dans les dix minutes avant minuit : midi ici,
// pour que les tests ne dépendent pas de l'heure à laquelle ils tournent.
oldNow := prewarmNow
prewarmNow = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) }
t.Cleanup(func() { prewarmNow = oldNow })
t.Helper()
withWorkspace(t)
activeProjMu.Lock()
+40 -25
View File
@@ -138,6 +138,11 @@ func recallGet(id string) (recallBlock, bool) {
if !ok {
return recallBlock{}, false
}
return recallGetSeq(seq)
}
// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade).
func recallGetSeq(seq uint64) (recallBlock, bool) {
var blk recallBlock
found := false
_ = view(bkRecall, func(b *bolt.Bucket) error {
@@ -155,6 +160,27 @@ func recallGet(id string) (recallBlock, bool) {
return blk, found
}
// recallAll : tous les blocs lisibles, par séquence croissante (un bloc
// chiffré sous une mémoire verrouillée n'en fait pas partie).
func recallAll() []recallBlock {
var out []recallBlock
_ = view(bkRecall, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var blk recallBlock
dec, err := decodeMemContent(v)
if err != nil {
return nil
}
if json.Unmarshal(dec, &blk) != nil {
return nil
}
out = append(out, blk)
return nil
})
})
return out
}
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
// Score = nombre de termes distincts de la requête présents dans (libellé +
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
@@ -176,33 +202,22 @@ func recallSearch(query string, limit int) []recallBlock {
score int
}
var hits []scored
_ = view(bkRecall, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var blk recallBlock
dec, err := decodeMemContent(v)
if err != nil {
return nil // chiffré et verrouillé : ce bloc ne participe pas
for _, blk := range recallAll() {
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if json.Unmarshal(dec, &blk) != nil {
return nil
if strings.Contains(body, t) {
score++
}
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if strings.Contains(body, t) {
score++
}
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
return nil
})
})
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
+3
View File
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
if externalActive() {
return tplProbeResult{}, false
}
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
return facadeTplCaps()
}
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
if tplProbeLast == nil {
+5 -2
View File
@@ -46,6 +46,9 @@ type imageResult struct {
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
// ddgImagesBase : DuckDuckGo (un faux pour les tests).
var ddgImagesBase = "https://duckduckgo.com"
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
@@ -75,7 +78,7 @@ func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
defer cancel()
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
body, err := ddgGet(ctx, page, "")
if err != nil {
return nil, err
@@ -84,7 +87,7 @@ func duckduckgoImages(query string, limit int) ([]imageResult, error) {
if m == nil {
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
}
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
api := ddgImagesBase + "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
if err != nil {
return nil, err
+4
View File
@@ -9,6 +9,10 @@ package loki
//
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
// la discussion, sa suppression les emporte.
//
// Port Rust de l'outil (lot 38) : rust/loki-rs/src/chat/crittool.rs, employé
// par les tours de la façade ; journal de parité dans
// parity_criteria_test.go.
import (
"encoding/json"
+18 -3
View File
@@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string {
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
// disque que mesurent ses essais.
// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
// leurs). L'optimiseur refuse de mesurer à côté d'eux : un build ou un
// serveur de dev prend le CPU et le disque que mesurent ses essais.
func bgJobsRunning() int {
n := localBgJobsRunning()
if facadeChat() {
var out struct {
Running int `json:"running"`
}
if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil {
n += out.Running
}
}
return n
}
// localBgJobsRunning : les jobs lancés par ce processus.
func localBgJobsRunning() int {
jobsMu.Lock()
defer jobsMu.Unlock()
n := 0
+21 -10
View File
@@ -90,17 +90,10 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
}
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
if role == "planner" {
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
}
caps, system := subagentView(role)
msgs := []Message{
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
{Role: "user", Content: task + framing},
{Role: "system", Content: system},
{Role: "user", Content: task + subagentFraming(role)},
}
var out strings.Builder
// Le sous-agent occupe le slot de la conversation du builder : une fois son
@@ -133,3 +126,21 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
}
return "[" + role + "]\n" + res
}
// subagentView : les capacités et le prompt système d'un rôle délégué —
// servis aussi à la façade (/internal/v1/turn/subagent, lot 47).
func subagentView(role string) (Caps, string) {
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
return caps, rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)
}
// subagentFraming : le cadrage ajouté à la tâche. Le rôle ne voit ni la
// discussion ni l'utilisateur. Son prompt de rôle parle à un humain
// (« termine en demandant confirmation ») ; ici son interlocuteur est le
// builder, et sa réponse est un résultat d'outil.
func subagentFraming(role string) string {
if role == "planner" {
return "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
}
return "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
}
+29
View File
@@ -2,6 +2,7 @@ package loki
import (
"context"
"encoding/json"
"strings"
"testing"
)
@@ -54,3 +55,31 @@ func TestSubagentRolesAreReadOnly(t *testing.T) {
}
}
}
// Lot 47 : la façade demande au Go le contexte isolé d'un rôle délégué —
// le même prompt système, les mêmes outils et capacités que toolSubagent.
func TestSubagentVueInterne(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", `{"role":"planner"}`)
if w.Code != 200 {
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
}
var v struct {
System string `json:"system"`
Tools []Tool `json:"tools"`
Caps Caps `json:"caps"`
}
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
caps, system := subagentView("planner")
if v.System != system || v.Caps != caps || len(v.Tools) != len(EnabledTools(caps)) {
t.Fatalf("vue : %+v", v.Caps)
}
for _, body := range []string{`{"role":"verifier"}`, `{"role":""}`, `{`} {
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", body); w.Code != 400 {
t.Fatalf("%s : %d", body, w.Code)
}
}
}
+1
View File
@@ -457,6 +457,7 @@ func sweepStaleCUDirs() {
}
func cdpShutdown() {
facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation
cdpMu.Lock()
defer cdpMu.Unlock()
if cdpCur != nil {
+30
View File
@@ -169,6 +169,9 @@ func cuPromptLine() string {
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
func toolCUOpen(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_open", args); ok {
return r
}
url, _ := args["url"].(string)
if strings.TrimSpace(url) == "" {
return "[erreur] url manquante"
@@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string {
}
func toolCUSnapshot() string {
if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok {
return r
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
@@ -200,6 +206,9 @@ func toolCUSnapshot() string {
}
func toolCUFind(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_find", args); ok {
return r
}
text, _ := args["text"].(string)
if strings.TrimSpace(text) == "" {
return "[erreur] texte de recherche manquant"
@@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string {
}
func toolCUClick(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_click", args); ok {
return r
}
ref, ok := intArg(args, "ref")
if !ok {
return "[erreur] ref manquant (numéro de l'élément)"
@@ -232,6 +244,9 @@ func toolCUClick(args map[string]any) string {
}
func toolCUType(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_type", args); ok {
return r
}
text, _ := args["text"].(string)
s, err := cdpGet()
if err != nil {
@@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string {
}
func toolCUKey(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_key", args); ok {
return r
}
key, _ := args["key"].(string)
s, err := cdpGet()
if err != nil {
@@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string {
}
func toolCUClickXY(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_click_xy", args); ok {
return r
}
x, okx := floatArg(args, "x")
y, oky := floatArg(args, "y")
if !okx || !oky {
@@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string {
}
func toolCUScroll(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_scroll", args); ok {
return r
}
dir, _ := args["direction"].(string)
s, err := cdpGet()
if err != nil {
@@ -299,6 +323,12 @@ func toolCUScroll(args map[string]any) string {
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
func toolCUScreenshot() (string, map[string]any) {
if r, img, ok := facadeBrowserTool("browser_screenshot", map[string]any{}); ok {
if len(img) == 0 {
img = nil
}
return r, img
}
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
}
+3
View File
@@ -46,6 +46,9 @@ func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n"
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
// bloquant pour l'outil).
func cuAutoShot() string {
if id, ok := facadeBrowserShot(); ok {
return id
}
s, err := cdpGet()
if err != nil {
cuLog("aperçu : navigateur indisponible : %v", err)
+4
View File
@@ -414,6 +414,10 @@ func asrShutdown() {
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
// l'utilisateur qui vient de demander la place, pas une panne.
func asrShutdownVite() {
// Dictée tenue par la façade (LOKI_DICTATE=rust) : son serveur est le sien.
if facadeDictateStop() {
return
}
for i := 0; i < 20; i++ {
if wsrvMu.TryLock() {
asrStopLocked()
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
)
// facade_backends.go — backends llama.cpp tenus par la façade Rust
// (LOKI_BACKENDS=rust, lot 22 de la migration ; expérimental, le Go reste le
// défaut) : statut, vérification git, binaires précompilés, bascule fast/opt,
// suivi du job, backends custom et GPU vus par un moteur
// (rust/loki-rs/src/backends).
//
// La façade détient alors LE job des moteurs, et compile elle-même llama.cpp
// depuis les sources sous Linux et macOS (install, update, install-custom ;
// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation
// sous Windows (chaîne MSVC), moteur de l'image, Strata — lui rapportent
// chaque étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail,
// lcDone, startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure
// plus de job au démarrage (la façade s'en charge).
const facadeBackendsEnv = "LOKI_BACKENDS"
// facadeBackends : la façade tient-elle les backends et le job ?
func facadeBackends() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBackendsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeBackendsRoutes : les routes que la façade sert alors (backends/mod.rs).
var facadeBackendsRoutes = map[string]bool{
"/api/llamacpp": true,
"/api/llamacpp/check": true,
"/api/llamacpp/prebuilt": true,
"/api/llamacpp/prebuilt/check": true,
"/api/llamacpp/use": true,
"/api/llamacpp/job": true,
"/api/llamacpp/uninstall-custom": true,
"/api/backends": true,
"/api/backends/custom": true,
"/api/backends/devices": true,
}
func init() {
// La compilation depuis les sources part aussi à la façade (lot 41), sauf
// sous Windows : générateur Visual Studio et intégration CUDA de MSBuild
// restent au Go.
if runtime.GOOS != "windows" {
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
facadeBackendsRoutes[p] = true
}
}
}
// facadeJobClient : appels courts (une ligne de journal, une phase).
var facadeJobClient = &http.Client{Timeout: 30 * time.Second}
// facadeJobCall envoie une étape du job à la façade. out peut être nil.
// Une réponse 409 rend l'erreur que la façade explique (job déjà en cours).
func facadeJobCall(op string, body map[string]any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/build-job/"+op, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = resp.Status
}
return errors.New(e.Error)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// facadeJobNote : une étape dont l'échec ne doit pas arrêter le job (le
// journal perd une ligne, rien de plus) — tracée sur la sortie d'erreur.
func facadeJobNote(op string, body map[string]any) {
if err := facadeJobCall(op, body, nil); err != nil {
fmt.Fprintf(os.Stderr, "[job] étape %s non transmise à la façade : %v\n", op, err)
}
}
// facadeJobStart démarre le job côté façade, puis son corps ici.
func facadeJobStart(action string, run func()) error {
if err := facadeJobCall("start", map[string]any{"action": action}, nil); err != nil {
return err
}
setBuildSink(lcAppend)
go func() {
defer func() {
setBuildSink(nil)
facadeJobNote("end", map[string]any{})
}()
run()
}()
return nil
}
// facadeJobSnapshot : la vue du job tenue par la façade.
func facadeJobSnapshot(from int, withLines bool) map[string]any {
lines := "0"
if withLines {
lines = "1"
}
req, err := http.NewRequest(http.MethodGet, "http://"+facadeAddr()+
"/internal/facade/v1/build-job/snapshot?from="+strconv.Itoa(from)+"&lines="+lines, nil)
if err != nil {
return map[string]any{"exists": false}
}
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return map[string]any{"exists": false}
}
defer resp.Body.Close()
var out map[string]any
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil {
return map[string]any{"exists": false}
}
return out
}
// lcSetCommit note le commit d'avant ("old") ou d'après ("new") une mise à
// jour.
func lcSetCommit(which, commit string) {
if facadeBackends() {
facadeJobNote("commits", map[string]any{which: commit})
return
}
lcMu.Lock()
if lcCur != nil {
if which == "old" {
lcCur.OldCommit = commit
} else {
lcCur.NewCommit = commit
}
}
lcMu.Unlock()
}
+15
View File
@@ -0,0 +1,15 @@
package loki
import "time"
// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre.
func resetBackendsState() {
lcMu.Lock()
lcCur = nil
lcLastSave = time.Time{}
lcMu.Unlock()
setBuildSink(nil)
devCacheMu.Lock()
devCache = map[string]devCacheEntry{}
devCacheMu.Unlock()
}
+95
View File
@@ -0,0 +1,95 @@
package loki
import (
"net/http"
"os"
"strings"
"sync"
)
// facade_bench.go — le banc d'essai tenu par la façade Rust (LOKI_BENCH=rust,
// lot 27 de la migration ; expérimental, le Go reste le défaut) : les routes
// /api/bench*, le job et le protocole de mesure (rust/loki-rs/src/bench). Le
// verrou de génération reste celui de la conversation du Go : la façade le
// prend par le bail du moteur, avec le motif « bench » (benching, et le refus
// errBenchBusy pour les autres). Le Go lui prête aussi l'isolation des travaux
// annexes (effacement du slot à la fin) et la télémétrie (perf_log.go).
const facadeBenchEnv = "LOKI_BENCH"
// facadeBench : la façade tient-elle le banc d'essai ?
func facadeBench() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBenchEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeBenchRoutes : les routes que la façade sert alors.
var facadeBenchRoutes = map[string]bool{
"/api/bench": true,
"/api/bench/status": true,
"/api/bench/cancel": true,
"/api/bench/last": true,
}
// facadeSideJobs : les travaux annexes ouverts par la façade (id → fin).
var facadeSideJobs = struct {
mu sync.Mutex
seq int
m map[int]func()
}{m: map[int]func(){}}
// handleInternalSideJob : POST {} ouvre un travail annexe (engineSideJob) et
// rend son id ; POST {"end": id, "served": bool} le ferme — served : le
// moteur a servi au moins une requête du travail (engineServed), le slot
// porte donc son état et peut être effacé.
func handleInternalSideJob(w http.ResponseWriter, r *http.Request) {
var in struct {
End int `json:"end"`
Served bool `json:"served"`
}
if !readLeaseBody(w, r, &in) {
return
}
if in.End == 0 {
done := engineSideJob()
facadeSideJobs.mu.Lock()
facadeSideJobs.seq++
id := facadeSideJobs.seq
facadeSideJobs.m[id] = done
facadeSideJobs.mu.Unlock()
sendJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
facadeSideJobs.mu.Lock()
done := facadeSideJobs.m[in.End]
delete(facadeSideJobs.m, in.End)
facadeSideJobs.mu.Unlock()
if done == nil {
sendJSON(w, http.StatusNotFound, map[string]any{"error": "travail annexe inconnu"})
return
}
if in.Served {
engineServed()
}
done()
w.WriteHeader(http.StatusNoContent)
}
// handleInternalBenchPerf : la trace d'un bench de la façade dans la
// télémétrie (le bench a pris le slot : la perte de cache du tour suivant lui
// revient).
func handleInternalBenchPerf(w http.ResponseWriter, r *http.Request) {
var in struct {
PromptN int `json:"prompt_n"`
PromptMs float64 `json:"prompt_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedPerSec float64 `json:"predicted_per_second"`
}
if !readLeaseBody(w, r, &in) {
return
}
perfRecord(perfRec{Kind: perfBench, Complete: true, Total: in.PromptN, New: in.PromptN,
PPms: in.PromptMs, PPtps: in.PromptPerSecond, Gen: in.PredictedN, TGtps: in.PredictedPerSec}, nil)
w.WriteHeader(http.StatusNoContent)
}
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// Le banc d'essai part à la façade avec LOKI_BENCH=rust (lot 27) ; le reste,
// non. La façade tient le verrou de génération par le bail du moteur, motif
// « bench », et le Go lui prête le travail annexe et la télémétrie.
func TestFacadeBench(t *testing.T) {
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(facadeBenchEnv, "")
if facadeBench() {
t.Fatal("tenu sans LOKI_BENCH")
}
for p := range facadeBenchRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_BENCH, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeBenchEnv, " RUST")
if len(facadeBenchRoutes) != 4 {
t.Fatalf("routes : %v", facadeBenchRoutes)
}
for p := range facadeBenchRoutes {
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(facadeBenchRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/tune", local)(w, httptest.NewRequest("POST", "/api/tune", nil))
if w.Body.String() != "go" {
t.Fatalf("l'optimiseur reste au Go : %q", w.Body.String())
}
// Le bail « bench » : le verrou, le refus du bench pour les autres.
post := func(h http.HandlerFunc, path, body string) (int, map[string]any) {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"bench"}`)
if code != 200 {
t.Fatalf("bail : %d %v", code, out)
}
id, _ := out["lease"].(string)
if !benchRunning() || conv.busyReason() != errBenchBusy {
t.Fatalf("bail du bench : running=%v refus=%v", benchRunning(), conv.busyReason())
}
if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errBenchBusy.Error() {
t.Fatalf("second bail : %d %v", code, out)
}
// Changer de discussion ne rompt pas le bail d'un bench.
conv.loadFrom("autre", nil)
if !benchRunning() || !conv.isGenerating() {
t.Fatal("le bail du bench a sauté à la bascule de discussion")
}
if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 {
t.Fatalf("libération : %d", code)
}
if benchRunning() || conv.isGenerating() || conv.busyReason() == errBenchBusy {
t.Fatal("bail rendu : le verrou devait être libre")
}
// Un bail ordinaire n'est pas un bench ; un bail de bench expiré rend
// le verrou et le refus.
code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{}`)
if code != 200 || benchRunning() {
t.Fatalf("bail ordinaire : %d %v bench=%v", code, out, benchRunning())
}
post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`)
conv.mu.Lock()
conv.extLeaseClearLocked()
conv.Generating = false
conv.mu.Unlock()
if _, err := conv.extLeaseAcquire(5*time.Second, errBenchBusy); err != nil {
t.Fatal(err)
}
conv.mu.Lock()
st := conv.extLease
conv.mu.Unlock()
conv.extLeaseExpire(st)
if benchRunning() || conv.busyReason() == errBenchBusy {
t.Fatal("bail de bench expiré : refus du bench encore là")
}
// Le travail annexe ouvert puis fermé par la façade.
code, out = post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{}`)
job, _ := out["id"].(float64)
if code != 200 || job <= 0 || sideJobs.Load() != 1 {
t.Fatalf("ouverture : %d %v (en cours : %d)", code, out, sideJobs.Load())
}
served := engineServedCount()
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":`+strconv.Itoa(int(job))+`,"served":true}`); code != 204 {
t.Fatalf("fermeture : %d", code)
}
if sideJobs.Load() != 0 || engineServedCount() != served+1 {
t.Fatalf("après la fermeture : en cours %d, servies %d", sideJobs.Load(), engineServedCount()-served)
}
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":999}`); code != 404 {
t.Fatalf("travail inconnu : %d", code)
}
rec := httptest.NewRecorder()
handleInternalSideJob(rec, httptest.NewRequest("GET", "/internal/v1/engine/sidejob", nil))
if rec.Code != 405 {
t.Fatalf("GET : %d", rec.Code)
}
// La trace du bench dans la télémétrie.
var before uint64
if recs := perfLog.snapshot(); len(recs) > 0 {
before = recs[len(recs)-1].Seq
}
if code, _ := post(handleInternalBenchPerf, "/internal/v1/bench/perf",
`{"prompt_n":2036,"prompt_ms":1018,"prompt_per_second":2000,"predicted_n":300,"predicted_per_second":50}`); code != 204 {
t.Fatalf("trace : %d", code)
}
recs := perfLog.snapshot()
if len(recs) == 0 || recs[len(recs)-1].Seq != before+1 {
t.Fatalf("trace absente (dernière avant : %d)", before)
}
r := recs[len(recs)-1]
if r.Kind != perfBench || !r.Complete || r.Total != 2036 || r.New != 2036 || r.Gen != 300 || r.TGtps != 50 || r.PPtps != 2000 || r.PPms != 1018 {
t.Fatalf("trace : %+v", r)
}
t.Setenv(internalTokenEnv, "")
if facadeBench() {
t.Fatal("tenu sans jeton interne")
}
}
+35
View File
@@ -0,0 +1,35 @@
package loki
import (
"strings"
"testing"
)
// Lot 43 : les jobs d'arrière-plan des tours de la façade comptent pour
// l'optimiseur du Go ; façade en panne, seuls les siens.
func TestFacadeJobsArrierePlan(t *testing.T) {
testHome(t)
jobsMu.Lock()
saved := jobs
jobs = map[string]*bgJob{"job1": {ID: "job1"}}
jobsMu.Unlock()
t.Cleanup(func() {
jobsMu.Lock()
jobs = saved
jobsMu.Unlock()
})
f := demarrerFacade(t)
f.internalStatus = 200
f.internalBody = `{"running":2}`
if n := bgJobsRunning(); n != 3 {
t.Fatalf("jobs : %d, attendu 3 (1 ici, 2 à la façade)", n)
}
vu := f.recu()
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/bgjobs jeton ") {
t.Fatalf("appel : %v", vu)
}
f.internalStatus = 500
if n := bgJobsRunning(); n != 1 {
t.Fatalf("façade en panne : %d", n)
}
}
+86
View File
@@ -0,0 +1,86 @@
package loki
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// facade_browser.go — le navigateur piloté tenu par la façade (lot 44 de la
// migration Rust). Quand elle tient la conversation, la session Chrome est
// la sienne : ses tours exécutent les outils browser_* chez elle, et ceux du
// Go (sous-agents, tâches IA) passent par /internal/facade/v1/browser — un
// seul navigateur. L'aperçu après une action et la fermeture aussi.
// Façade injoignable : le navigateur du Go prend le relais.
// facadeBrowserClient : une action du navigateur peut attendre le
// lancement de Chrome, puis le chargement et le calme réseau de la page.
var facadeBrowserClient = &http.Client{Timeout: 3 * time.Minute}
// facadeBrowserCall appelle l'API du navigateur de la façade.
func facadeBrowserCall(body map[string]any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/browser", bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeBrowserClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("façade : %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// facadeBrowserTool exécute un outil browser_* à la façade ; ok faux : elle
// ne tient pas la conversation, ou ne répond pas (le Go le fait lui-même).
func facadeBrowserTool(name string, args map[string]any) (result string, image map[string]any, ok bool) {
if !facadeChat() {
return "", nil, false
}
var out struct {
Result string `json:"result"`
Image map[string]any `json:"image"`
}
if err := facadeBrowserCall(map[string]any{"op": "tool", "name": name, "args": args}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[cu] navigateur de la façade injoignable, repli local : %v\n", err)
return "", nil, false
}
return out.Result, out.Image, true
}
// facadeBrowserShot : l'aperçu pris par la façade.
func facadeBrowserShot() (string, bool) {
if !facadeChat() {
return "", false
}
var out struct {
ID string `json:"id"`
}
if err := facadeBrowserCall(map[string]any{"op": "shot"}, &out); err != nil {
return "", false
}
return out.ID, true
}
// facadeBrowserShutdown ferme le navigateur de la façade.
func facadeBrowserShutdown() {
if !facadeChat() {
return
}
var out struct{}
if err := facadeBrowserCall(map[string]any{"op": "shutdown"}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[cu] fermeture du navigateur de la façade : %v\n", err)
}
}
+53
View File
@@ -0,0 +1,53 @@
package loki
import (
"strings"
"testing"
)
// Lot 44 : quand la façade tient la conversation, le navigateur est le sien —
// les outils browser_* du Go, l'aperçu et la fermeture passent par elle ;
// façade en panne, le navigateur du Go prend le relais.
func TestFacadeNavigateur(t *testing.T) {
testHome(t)
t.Setenv("LOKI_CHROME", "/nulle/part/chrome")
f := demarrerFacade(t)
f.internalStatus = 200
f.internalBody = `{"result":"# Exemple\nhttps://exemple.test/","image":null}`
if got := toolCUOpen(map[string]any{"url": "exemple.test"}); got != "# Exemple\nhttps://exemple.test/" {
t.Fatalf("ouverture : %q", got)
}
vu := f.recu()
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/browser jeton ") ||
!strings.Contains(vu[0], `"op":"tool"`) || !strings.Contains(vu[0], `"name":"browser_open"`) ||
!strings.Contains(vu[0], `"url":"exemple.test"`) {
t.Fatalf("appel : %v", vu)
}
f.internalBody = `{"result":"[ok] capture","image":{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}}`
res, img := toolCUScreenshot()
if res != "[ok] capture" || img["type"] != "image_url" {
t.Fatalf("capture : %q %v", res, img)
}
f.internalBody = `{"result":"[erreur] la vision n'est pas active","image":null}`
if res, img := toolCUScreenshot(); img != nil || !strings.HasPrefix(res, "[erreur]") {
t.Fatalf("capture refusée : %q %v", res, img)
}
f.internalBody = `{"id":"abc"}`
if id := cuAutoShot(); id != "abc" {
t.Fatalf("aperçu : %q", id)
}
f.internalBody = `{}`
cdpShutdown()
vu = f.recu()
if last := vu[len(vu)-1]; !strings.Contains(last, `"op":"shutdown"`) {
t.Fatalf("fermeture : %v", vu)
}
if !facadeChatRoutes["/api/chat/cushot"] {
t.Fatal("/api/chat/cushot n'est pas remis à la façade")
}
// Façade en panne : le Go pilote lui-même.
f.internalStatus = 500
if got := toolCUSnapshot(); got != "[erreur] fork/exec /nulle/part/chrome: no such file or directory" {
t.Fatalf("repli : %q", got)
}
}
+106 -70
View File
@@ -39,67 +39,14 @@ const (
)
// facadeChat : la façade tient-elle la conversation ? Il faut aussi son
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis.
// Plus aucun réglage ne garde la conversation ici : la façade honore
// NUDGE_IN_TOOL (lot 23), les motifs de relance personnalisés (lot 30),
// SIDE_SLOT et SLOT_PERSIST (lot 31), KEEP_TURN_IMAGES (lot 32), PREWARM
// (lot 33) et PROJ_SNAPSHOT (lot 34, facade_projsnap.go).
func facadeChat() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == ""
}
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, images gardées, slots du moteur, préchauffage, motifs de relance
// personnalisés).
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case cfgOn(cfg, "NUDGE_IN_TOOL"):
return "NUDGE_IN_TOOL"
case sideSlotOn(cfg):
return "SIDE_SLOT"
case slotPersistOn(cfg):
return "SLOT_PERSIST"
case prewarmMode(cfg) != "":
return "PREWARM"
case strings.TrimSpace(getStr(bkState, "retry_patterns")) != "":
return "retry_patterns"
}
return ""
}
// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in).
func cfgOn(cfg map[string]string, k string) bool {
switch strings.ToLower(strings.TrimSpace(cfg[k])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// Décision prise UNE fois par process : passer la conversation d'un détenteur
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
// changé vaut au redémarrage suivant. La façade la lit au démarrage
// (/internal/v1/chat/holder) et s'y range.
var (
facadeDecisionMu sync.Mutex
facadeDecisionDone bool
facadeDecisionWhy string
)
func facadeChatDecision() string {
facadeDecisionMu.Lock()
defer facadeDecisionMu.Unlock()
if !facadeDecisionDone {
facadeDecisionWhy = facadeChatBlocker(ReadConfig())
facadeDecisionDone = true
if facadeDecisionWhy != "" {
fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy)
}
}
return facadeDecisionWhy
facadeAddr() != "" && internalToken() != ""
}
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
@@ -113,7 +60,7 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
return
}
why := facadeChatDecision()
why := "adresse de la façade ou jeton interne manquant"
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
why = facadeChatEnv + " absent"
}
@@ -123,8 +70,8 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
// facadeChatRoutes : les routes que la façade sert quand elle tient la
// conversation (chatapi.rs). Le reste (mode Code, critères, pièces jointes,
// fichiers, export) reste ici.
// conversation (chatapi.rs, chatx.rs). Le reste du mode Code (indice, travaux
// de fond, outil criteria) reste ici et lit les mêmes clés.
var facadeChatRoutes = map[string]bool{
"/api/chat": true,
"/api/chat/send": true,
@@ -138,34 +85,98 @@ var facadeChatRoutes = map[string]bool{
"/api/conversations/switch": true,
"/api/conversations/rename": true,
"/api/conversations/delete": true,
// Lot 19 : export, mode Code et critères de la discussion ouverte.
"/api/chat/export": true,
"/api/chat/mode": true,
"/api/chat/criteria": true,
// Lot 39 : l'anneau de télémétrie est celui de la façade.
"/api/perf/summary": true,
// Lot 44 : les aperçus du navigateur piloté, tenu par la façade.
"/api/chat/cushot": true,
}
// withFacadeChat remet la requête à la façade quand elle tient la
// conversation. Posé APRÈS requireWebAuth : seule une requête authentifiée
// ici part, avec le jeton interne qui en tient lieu là-bas.
// conversation — ou les routes des modèles (facade_models.go), la dictée
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
// de la discussion (facade_files.go), les notifications (facade_push.go), la
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à
// jour (facade_update.go), les backends llama.cpp (facade_backends.go), le
// moteur de l'image (facade_engineimg.go), Strata (facade_strata.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
if !facadeChatRoutes[path] {
held, unavailable := facadeChat, "conversation indisponible : "
switch {
case facadeChatRoutes[path]:
case facadeModelRoutes[path]:
held, unavailable = facadeModels, "modèles indisponibles : "
case facadeDictateRoutes[path]:
held, unavailable = facadeDictate, "dictée indisponible : "
case facadeSettingsRoutes[path]:
held, unavailable = facadeSettings, "réglages indisponibles : "
case facadeFilesRoutes[path]:
held, unavailable = facadeFiles, "fichiers indisponibles : "
case facadePushRoutes[path]:
held, unavailable = facadePush, "notifications indisponibles : "
case facadeMemRoutes[path]:
held, unavailable = facadeMem, "mémoire indisponible : "
case facadePresetsRoutes[path]:
held, unavailable = facadePresets, "presets indisponibles : "
case facadeEngineRoutes[path]:
held, unavailable = facadeEngine, "pilotage du moteur indisponible : "
case facadeSystemRoutes[path]:
held, unavailable = facadeSystem, "réglages système indisponibles : "
case facadeProjectsRoutes[path]:
held, unavailable = facadeProjects, "projets indisponibles : "
case facadeMCPRoutes[path]:
held, unavailable = facadeMCP, "serveurs MCP indisponibles : "
case facadeUpdateRoutes[path]:
held, unavailable = facadeUpdate, "mise à jour indisponible : "
case facadeBackendsRoutes[path]:
held, unavailable = facadeBackends, "moteurs indisponibles : "
case facadeEngineImageRoutes[path]:
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 : "
case facadeBenchRoutes[path]:
held, unavailable = facadeBench, "banc d'essai indisponible : "
case facadeTuneRoutes[path]:
held, unavailable = facadeTune, "optimiseur indisponible : "
case facadeCoreRoutes[path]:
held, unavailable = facadeCore, "façade indisponible : "
default:
return h
}
return func(w http.ResponseWriter, r *http.Request) {
if !facadeChat() {
if !held() {
h(w, r)
return
}
facadeRelay(w, r)
facadeRelayAs(w, r, unavailable)
}
}
// facadeRelay : relais transparent (flux compris) vers la façade.
func facadeRelay(w http.ResponseWriter, r *http.Request) {
// facadeRelayAs : relais transparent (flux compris) vers la façade ;
// unavailable préfixe l'erreur si elle ne répond pas.
func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) {
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()})
// L'hôte et le schéma du client, pas ceux du relais : la clé de
// complétion affiche l'adresse à coller dans un client tiers.
pr.Out.Host = pr.In.Host
if p := pr.In.Header.Get("X-Forwarded-Proto"); p != "" {
pr.Out.Header.Set("X-Forwarded-Proto", p)
}
pr.Out.Header.Set(internalTokenHeader, internalToken())
},
FlushInterval: -1, // SSE : chaque trame part aussitôt
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "conversation indisponible : " + err.Error()})
sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": unavailable + err.Error()})
},
}
rp.ServeHTTP(w, r)
@@ -197,6 +208,31 @@ func facadePost(path string, body any) error {
return nil
}
// facadeTplCaps : le dernier verdict de la sonde de gabarit de la façade,
// quand elle tient la conversation (c'est elle qui sonde après ses
// complétions).
func facadeTplCaps() (tplProbeResult, bool) {
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/chat/tpl", strings.NewReader("{}"))
if err != nil {
return tplProbeResult{}, false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeClient.Do(req)
if err != nil {
return tplProbeResult{}, false
}
defer resp.Body.Close()
var out struct {
OK bool `json:"ok"`
Result tplProbeResult `json:"result"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK {
return tplProbeResult{}, false
}
return out.Result, true
}
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
type facadeError struct {
status int
+886 -52
View File
@@ -2,12 +2,17 @@ package loki
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux.
@@ -23,22 +28,8 @@ type fausseFacade struct {
internalBody string
}
// oublierDecisionFacade : la décision « qui tient la conversation » est prise
// une fois par process ; chaque test repart de zéro.
func oublierDecisionFacade(t *testing.T) {
t.Helper()
reset := func() {
facadeDecisionMu.Lock()
facadeDecisionDone, facadeDecisionWhy = false, ""
facadeDecisionMu.Unlock()
}
reset()
t.Cleanup(reset)
}
func demarrerFacade(t *testing.T) *fausseFacade {
t.Helper()
oublierDecisionFacade(t)
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
@@ -94,17 +85,25 @@ func TestFacadeRelaieLesRoutesDuChat(t *testing.T) {
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Mode Code : reste au Go.
// Mode Code, critères, export : à la façade aussi (lot 19).
for _, p := range []string{"/api/chat/mode", "/api/chat/criteria", "/api/chat/export"} {
w = httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
// L'aperçu du navigateur piloté aussi : le navigateur est le sien (lot 44).
w = httptest.NewRecorder()
withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil))
if w.Body.String() != "go" {
t.Fatalf("mode relayé : %q", w.Body.String())
withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil))
if w.Body.String() != f.reply {
t.Fatalf("aperçu non relayé : %q", w.Body.String())
}
// Sans LOKI_CHAT=rust : tout reste ici.
t.Setenv(facadeChatEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil))
if w.Body.String() != "go" || len(f.recu()) != 1 {
if w.Body.String() != "go" || len(f.recu()) != 5 {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
@@ -254,52 +253,887 @@ func TestFacadeActivationDeProjet(t *testing.T) {
}
}
// Un réglage que la façade ne sait pas honorer garde la conversation ici :
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
// jusqu'au redémarrage.
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
// Plus aucun réglage ne garde la conversation ici (lot 34) : avec chacune des
// clés opt-in posée, la façade la tient et le trafic de chat lui est remis.
// Sans LOKI_CHAT=rust, ou sans son adresse : le Go, et pourquoi.
func TestFacadeAucunReglageNeGardeLaConversation(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(internalTokenEnv, jetonTest)
for _, k := range []string{"PROJ_SNAPSHOT", "NUDGE_IN_TOOL", "SIDE_SLOT", "SLOT_PERSIST", "KEEP_TURN_IMAGES", "PREWARM"} {
if err := SetConfigKey(k, "on"); err != nil {
t.Fatal(err)
}
}
putStr(bkState, "retry_patterns", `["oups"]`)
if !facadeChat() {
t.Fatal("réglages opt-in posés : la façade doit tenir la conversation")
}
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) {
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
}
// Posé après la décision : sans effet avant le redémarrage.
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
t.Fatal(err)
}
if !facadeChat() {
t.Fatal("décision changée en cours de route")
}
oublierDecisionFacade(t)
if facadeChat() {
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
}
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
t.Fatalf("détenteur : %s", w.Body.String())
}
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
rec := httptest.NewRecorder()
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
if rec.Body.String() != "go" || len(f.recu()) != 0 {
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
if rec.Body.String() == "go" || len(f.recu()) != 1 {
t.Fatalf("pas remis à la façade : %q %v", rec.Body.String(), f.recu())
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT",
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
t.Errorf("%s : %q", k, got)
}
putStr(bkState, "retry_patterns", "")
t.Setenv(facadeAddrEnv, "")
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "adresse de la façade") {
t.Fatalf("sans adresse : %s", w.Body.String())
}
if got := facadeChatBlocker(map[string]string{}); got != "" {
t.Errorf("sans réglage : %q", got)
}
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
oublierDecisionFacade(t)
t.Setenv(facadeChatEnv, "")
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
t.Fatalf("sans la variable : %s", w.Body.String())
}
}
// LOKI_MODELS=rust : les routes des modèles partent à la façade (avec ou sans
// LOKI_CHAT), les autres restent ici ; sans la variable, rien ne change.
// Façade muette : erreur qui le dit.
func TestFacadeRelaieLesModeles(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeModelsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
routes := []string{"/api/models/download", "/api/models/download/probe",
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
for _, p := range routes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`)))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if got := f.recu(); len(got) != len(routes) || got[0] != `POST /api/models/download jeton {"url":"u"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi.
for _, p := range []string{"/api/chat/send", "/api/mem/health", "/api/presets"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s relayé : %q", p, w.Body.String())
}
}
// Façade injoignable : 502 qui nomme ce qui manque.
f.srv.Close()
w := httptest.NewRecorder()
withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil))
if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "modèles indisponibles : ") {
t.Fatalf("façade muette : %d %s", w.Code, w.Body.String())
}
// Sans LOKI_MODELS=rust : tout reste ici.
t.Setenv(facadeModelsEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les
// modèles : elle les vide à son démarrage, et un Go relancé seul
// effacerait celui qu'elle mène.
func TestFacadeTelechargementsGardeLesPart(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "models")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
part := filepath.Join(dir, "m.gguf.part")
if err := os.WriteFile(part, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
t.Setenv(facadeModelsEnv, "rust")
cleanStartupPartFiles()
if _, err := os.Stat(part); err != nil {
t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err)
}
t.Setenv(facadeModelsEnv, "")
cleanStartupPartFiles()
if _, err := os.Stat(part); !os.IsNotExist(err) {
t.Fatalf(".part orphelin gardé : %v", err)
}
}
// LOKI_DICTATE=rust : les routes de la dictée partent à la façade, et arrêter
// la dictée (libération de la VRAM, optimiseur) le lui demande au lieu de
// toucher au serveur de ce process. Sans la variable, rien ne change.
func TestFacadeDictee(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeDictateEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeDictateRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
asrShutdownVite()
got := f.recu()
if len(got) != len(facadeDictateRoutes)+1 || got[len(got)-1] != "POST /internal/facade/v1/dictate/stop jeton {}" {
t.Fatalf("reçu par la façade : %v", got)
}
t.Setenv(facadeDictateEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/transcribe", local)(w, httptest.NewRequest("POST", "/api/transcribe", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
asrShutdownVite()
if n := len(f.recu()); n != len(facadeDictateRoutes)+1 {
t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n)
}
}
// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la
// variable, ils restent ici.
func TestFacadeReglages(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeSettingsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeSettingsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeSettingsRoutes) {
t.Fatalf("%d appels reçus par la façade", n)
}
t.Setenv(facadeSettingsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// LOKI_FILES=rust : les routes des fichiers de la discussion partent à la
// façade ; /internal/v1/conv/workspace lui donne le dossier de la discussion
// active ; les .part d'envois ne sont plus vidés ici au démarrage.
func TestFacadeFichiers(t *testing.T) {
home := testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeFilesEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeFilesRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
prev := agentWorkspace()
workspacePath = filepath.Join(home, "ws")
t.Cleanup(func() { workspacePath = prev })
convActiveRemember("c-test")
w := httptest.NewRecorder()
handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}")))
var ws map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &ws); err != nil {
t.Fatal(err)
}
if ws["dir"] != filepath.Join(home, "ws", "discussions", "c-test") || ws["root"] != filepath.Join(home, "ws") || ws["id"] != "c-test" {
t.Fatalf("dossier de la discussion : %v", ws)
}
if st, err := os.Stat(ws["dir"]); err != nil || !st.IsDir() {
t.Fatalf("dossier non créé : %v", err)
}
part := filepath.Join(ws["dir"], "uploads", ".upload-1.part")
_ = os.MkdirAll(filepath.Dir(part), 0o755)
_ = os.WriteFile(part, []byte("x"), 0o644)
cleanStartupUploadParts()
if _, err := os.Stat(part); err != nil {
t.Fatalf(".part effacé alors que la façade tient les fichiers : %v", err)
}
t.Setenv(facadeFilesEnv, "")
cleanStartupUploadParts()
if _, err := os.Stat(part); !os.IsNotExist(err) {
t.Fatalf(".part gardé sans la variable : %v", err)
}
}
// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les
// envois de ce process (fin de tour, fin de tâche) lui sont délégués.
func TestFacadeNotifications(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadePushEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadePushRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
sendPushToAll("Loki", "Réponse prête · 3s")
got := f.recu()
if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` {
t.Fatalf("reçu par la façade : %v", got)
}
t.Setenv(facadePushEnv, "")
sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué
if n := len(f.recu()); n != len(facadePushRoutes)+1 {
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
}
}
func TestFacadeMemoire(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
// Sans la variable : le Go sert lui-même.
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String())
}
}
t.Setenv(facadeMemEnv, "rust")
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeMemRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Les routes voisines (coffre, projets) restent au Go.
for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s remis à la façade : %q", p, w.Body.String())
}
}
}
func TestFacadePresets(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadePresetsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_PRESETS : %q", p, w.Body.String())
}
}
t.Setenv(facadePresetsEnv, "rust")
for p := range facadePresetsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadePresetsRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
}
// Le pilotage du moteur (LOKI_ENGINE=rust, lot 16) part à la façade, et l'API
// interne lui rend ce qui reste ici : verrou de l'optimiseur avant un
// démarrage, génération en cours, arrêt de la dictée.
func TestFacadeMoteur(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeEngineRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_ENGINE : %q", p, w.Body.String())
}
}
t.Setenv(facadeEngineEnv, "rust")
for p := range facadeEngineRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeEngineRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Avant un démarrage : le verrou, puis le ménage.
appels := 0
old := enginePrestart
t.Cleanup(func() { enginePrestart = old })
enginePrestart = func() error {
appels++
if appels == 2 {
return errTuneBusy
}
return nil
}
for i, want := range []string{`{"error":""}`, `{"error":"` + errTuneBusy.Error() + `"}`} {
w := httptest.NewRecorder()
handleInternalServicePrestart(w, httptest.NewRequest("POST", "/internal/v1/service/prestart", nil))
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != want {
t.Fatalf("prestart %d : %d %s", i, w.Code, got)
}
}
w := httptest.NewRecorder()
handleInternalServicePrestart(w, httptest.NewRequest("GET", "/internal/v1/service/prestart", nil))
if w.Code != 405 || appels != 2 {
t.Fatalf("prestart en GET : %d (%d appels)", w.Code, appels)
}
// La génération en cours de la conversation tenue ici.
for _, gen := range []bool{false, true} {
conv.mu.Lock()
conv.Generating = gen
conv.mu.Unlock()
w := httptest.NewRecorder()
handleInternalChatGenerating(w, httptest.NewRequest("GET", "/internal/v1/chat/generating", nil))
var out struct{ Generating bool }
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || out.Generating != gen {
t.Fatalf("generating=%v : %s", gen, w.Body.String())
}
}
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
// L'arrêt de la dictée (rien ne tourne : immédiat).
w = httptest.NewRecorder()
handleInternalDictateStop(w, httptest.NewRequest("POST", "/internal/v1/dictate/stop", nil))
if w.Code != 204 {
t.Fatalf("arrêt de la dictée : %d", w.Code)
}
}
// Une clé de complétion d'un ou deux octets ne fait plus tomber /api/apikey.
func TestMaskAPIKeyCourte(t *testing.T) {
for k, want := range map[string]string{
"": "",
"k": "…",
"ab": "…",
"abc": "…bc",
"abcdefghijklm": "…lm",
"sk-loki-0123456789abcdef": "sk-loki-0…cdef",
} {
if got := maskAPIKey(k); got != want {
t.Errorf("maskAPIKey(%q) = %q, attendu %q", k, got, want)
}
}
}
// Les interrupteurs système (LOKI_SYSTEM=rust, lot 17) partent à la façade ;
// l'API interne lui rend ce qui vit ici : cache de joignabilité des outils
// web, navigateur piloté, redémarrage du moteur.
func TestFacadeSysteme(t *testing.T) {
testHome(t)
t.Setenv("LOKI_CONTAINER", "1")
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeSystemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_SYSTEM : %q", p, w.Body.String())
}
}
t.Setenv(facadeSystemEnv, "rust")
for p := range facadeSystemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeSystemRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Cache de joignabilité vidé.
reachMu.Lock()
reachURL = "http://ancien"
reachMu.Unlock()
w := httptest.NewRecorder()
handleInternalReachReset(w, httptest.NewRequest("POST", "/internal/v1/web/reach/reset", nil))
reachMu.Lock()
vide := reachURL == ""
reachMu.Unlock()
if w.Code != 204 || !vide {
t.Fatalf("reach/reset : %d, cache vidé : %v", w.Code, vide)
}
w = httptest.NewRecorder()
handleInternalCDPShutdown(w, httptest.NewRequest("POST", "/internal/v1/cdp/shutdown", nil))
if w.Code != 204 {
t.Fatalf("cdp/shutdown : %d", w.Code)
}
// Redémarrage : seulement si le moteur tourne quand if_active est posé.
var actions []string
old := internalServiceAction
t.Cleanup(func() { internalServiceAction = old })
internalServiceAction = func(a string) error {
actions = append(actions, a)
if a == "start" {
return errTuneBusy
}
return nil
}
for _, c := range []struct {
body, want string
code int
}{
{`{"action":"restart","if_active":true}`, `{"error":"","ran":false}`, 200},
{`{"action":"restart"}`, `{"error":"","ran":true}`, 200},
{`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`, 200},
{`{"action":"reload"}`, `{"error":"action inconnue : reload"}`, 400},
{`{"action":`, `{"error":"unexpected EOF"}`, 400},
} {
w := httptest.NewRecorder()
handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body)))
if got := strings.TrimSpace(w.Body.String()); w.Code != c.code || got != c.want {
t.Fatalf("%s : %d %s", c.body, w.Code, got)
}
}
if strings.Join(actions, ",") != "restart,start" {
t.Fatalf("actions : %v", actions)
}
}
// Le relais vers la façade garde l'hôte et le schéma du client : la clé de
// complétion y calcule l'adresse à coller dans un client tiers.
func TestFacadeRelaisGardeHote(t *testing.T) {
var host, proto string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, proto = r.Host, r.Header.Get("X-Forwarded-Proto")
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
r := httptest.NewRequest("GET", "/api/apikey", nil)
r.Host = "loki.exemple.fr"
r.Header.Set("X-Forwarded-Proto", "https")
facadeRelayAs(httptest.NewRecorder(), r, "")
if host != "loki.exemple.fr" || proto != "https" {
t.Fatalf("hôte %q, schéma %q", host, proto)
}
}
// Le hub des projets (LOKI_PROJECTS=rust, lot 18) part à la façade ; quand le
// Go tient la conversation, la bascule et les discussions d'un projet
// supprimé restent ici (API interne).
func TestFacadeProjets(t *testing.T) {
testHome(t)
// Le projet actif est gardé en RAM d'un test à l'autre.
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
t.Cleanup(func() {
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
})
ensureDefaultProject()
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
w := httptest.NewRecorder()
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("GET", "/api/projects", nil))
if w.Body.String() != "go" {
t.Fatalf("servi ailleurs sans LOKI_PROJECTS : %q", w.Body.String())
}
t.Setenv(facadeProjectsEnv, "rust")
w = httptest.NewRecorder()
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader("{}")))
if w.Body.String() != f.reply || len(f.recu()) != 1 {
t.Fatalf("relais : %q, %d appels", w.Body.String(), len(f.recu()))
}
if _, err := createProject("Alpha"); err != nil {
t.Fatal(err)
}
convIndexSave([]convMeta{{ID: "a1", Project: "alpha", Updated: 1}, {ID: "g1", Project: "generale", Updated: 2}})
_ = putBytes(bkChat, convKey("a1"), []byte(`{"id":"a1"}`))
_ = putBytes(bkChat, convKey("g1"), []byte(`{"id":"g1"}`))
post := func(h http.HandlerFunc, body string) (int, string) {
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", "/internal/v1/project/x", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if c, b := post(handleInternalProjectSwitch, `{"slug":"nope"}`); c != 400 || b != `{"error":"projet introuvable"}` {
t.Fatalf("bascule inconnue : %d %s", c, b)
}
if c, _ := post(handleInternalProjectSwitch, `{"slug":" alpha "}`); c != 204 || activeProjectSlug() != "alpha" {
t.Fatalf("bascule : %d, actif %q", c, activeProjectSlug())
}
conv.mu.Lock()
conv.Generating = true
conv.mu.Unlock()
c1, _ := post(handleInternalProjectSwitch, `{"slug":"generale"}`)
c2, _ := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`)
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
if c1 != 409 || c2 != 409 {
t.Fatalf("pendant une génération : %d, %d", c1, c2)
}
if c, b := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`); c != 200 || b != `{"dropped":1}` {
t.Fatalf("discussions supprimées : %d %s", c, b)
}
if n := len(convIndexForProject("alpha")); n != 0 || len(convIndexForProject("generale")) == 0 {
t.Fatalf("index après : %d d'Alpha", n)
}
if c, _ := post(handleInternalProjectDropConvs, `nimp`); c != 400 {
t.Fatalf("corps illisible : %d", c)
}
}
// Les serveurs MCP (LOKI_MCP=rust, lot 20) : routes relayées, et le Go
// demande à la façade les outils MCP et leurs appels (un seul pool).
func TestFacadeMCP(t *testing.T) {
testHome(t)
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
}
b, _ := io.ReadAll(r.Body)
calls = append(calls, r.URL.Path+" "+string(b))
switch r.URL.Path {
case "/internal/facade/v1/mcp/tools":
io.WriteString(w, `{"tools":[{"type":"function","function":{"name":"mcp__a__t","description":"[MCP: a] d","parameters":{"type":"object"}}}]}`)
case "/internal/facade/v1/mcp/call":
io.WriteString(w, `{"result":"fait"}`)
case "/internal/facade/v1/mcp/prewarm":
io.WriteString(w, `{"ok":true}`)
default:
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(facadeMCPEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/mcp", local)(w, httptest.NewRequest("GET", "/api/mcp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_MCP : %q", w.Body.String())
}
t.Setenv(facadeMCPEnv, "rust")
for p := range facadeMCPRoutes {
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())
}
}
tools := mcpTools()
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
for i := 0; i < 50 && !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm"); i++ {
time.Sleep(20 * time.Millisecond)
}
if !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm") {
t.Fatalf("pré-chauffage non demandé : %v", calls)
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
t.Fatalf("outils sans façade : %v", tools)
}
}
// La mise à jour (LOKI_UPDATE=rust, lot 21) part à la façade.
func TestFacadeMiseAJour(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for _, on := range []string{"", "rust"} {
t.Setenv(facadeUpdateEnv, on)
for p := range facadeUpdateRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
want := "go"
if on == "rust" {
want = f.reply
}
if w.Body.String() != want {
t.Fatalf("%s (LOKI_UPDATE=%q) : %q", p, on, w.Body.String())
}
}
}
}
// Lot 22 : les routes des backends partent à la façade, et les jobs du Go
// (compilation, moteur de l'image, Strata) lui rapportent chaque étape — le
// job vit chez elle.
func TestFacadeBackends(t *testing.T) {
testHome(t)
resetBackendsState()
t.Cleanup(resetBackendsState)
var mu sync.Mutex
var calls []string
busy := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
b, _ := io.ReadAll(r.Body)
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.RequestURI()+" "+string(b))
refuse := busy
mu.Unlock()
switch r.URL.Path {
case "/internal/facade/v1/build-job/start":
if refuse {
w.WriteHeader(409)
io.WriteString(w, `{"ok":false,"error":"un job engine est déjà en cours"}`)
return
}
io.WriteString(w, `{"ok":true}`)
case "/internal/facade/v1/build-job/snapshot":
io.WriteString(w, `{"exists":true,"action":"engine","running":true}`)
default:
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(facadeBackendsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp", local)(w, httptest.NewRequest("GET", "/api/llamacpp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_BACKENDS : %q", w.Body.String())
}
t.Setenv(facadeBackendsEnv, "rust")
for p := range facadeBackendsRoutes {
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())
}
}
// La compilation depuis les sources part aussi (lot 41), sauf sous Windows.
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
if facadeBackendsRoutes[p] != (runtime.GOOS != "windows") {
t.Fatalf("%s : tenu par la façade = %v sous %s", p, facadeBackendsRoutes[p], runtime.GOOS)
}
}
mu.Lock()
calls = nil
mu.Unlock()
// Un job du Go : chaque étape part à la façade, dans l'ordre.
done := make(chan struct{})
if err := startLcJob("engine", func() {
lcPhase("recherche…")
emitBuildLine("[ 10%] Building CXX object a/b.cpp.o")
lcSetCommit("old", "")
lcSetCommit("new", "bbb")
lcFail(errors.New("raté"))
lcDone("fini")
close(done)
}); err != nil {
t.Fatal(err)
}
<-done
want := []string{
`POST /internal/facade/v1/build-job/start {"action":"engine"}`,
`POST /internal/facade/v1/build-job/phase {"phase":"recherche…"}`,
`POST /internal/facade/v1/build-job/line {"line":"[ 10%] Building CXX object a/b.cpp.o"}`,
`POST /internal/facade/v1/build-job/commits {"old":""}`,
`POST /internal/facade/v1/build-job/commits {"new":"bbb"}`,
`POST /internal/facade/v1/build-job/fail {"error":"raté"}`,
`POST /internal/facade/v1/build-job/done {"msg":"fini"}`,
`POST /internal/facade/v1/build-job/end {}`,
}
for i := 0; i < 100; i++ {
mu.Lock()
n := len(calls)
mu.Unlock()
if n >= len(want) {
break
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
got := append([]string(nil), calls...)
calls = nil
busy = true
mu.Unlock()
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("étapes transmises :\n%s", strings.Join(got, "\n"))
}
// Rien ne reste dans le job du Go, et le sink est retiré.
if lcCur != nil {
t.Fatalf("job tenu par le Go : %+v", lcCur)
}
// Un second job : la façade le refuse, le Go le dit.
if err := startLcJob("strata", func() { t.Error("ne doit pas tourner") }); err == nil || err.Error() != "un job engine est déjà en cours" {
t.Fatalf("refus : %v", err)
}
// La vue du job (statut du moteur de l'image) vient de la façade.
if v := lcJobSnapshot(0, false); v["action"] != "engine" || v["running"] != true {
t.Fatalf("vue : %v", v)
}
mu.Lock()
last := calls[len(calls)-1]
mu.Unlock()
if last != "GET /internal/facade/v1/build-job/snapshot?from=0&lines=0 " {
t.Fatalf("vue demandée : %q", last)
}
if v := lcJobSnapshot(3, true); v["exists"] != true {
t.Fatalf("vue avec lignes : %v", v)
}
// Façade injoignable : le job ne démarre pas, la vue dit « aucun job ».
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if err := startLcJob("engine", func() { t.Error("ne doit pas tourner") }); err == nil {
t.Fatal("job démarré sans façade")
}
if v := lcJobSnapshot(0, true); v["exists"] != false {
t.Fatalf("vue sans façade : %v", v)
}
lcAppend("perdue") // tracée, sans panique
}
// Lot 23 : quand la façade tient la conversation, c'est elle qui sonde le
// gabarit ; le résumé des performances relit son verdict.
func TestFacadeSondeDeGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" {
w.WriteHeader(404)
return
}
io.WriteString(w, verdict)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
t.Setenv(facadeChatEnv, "rust")
r, ok := tplCapsCurrent()
if !ok || r.PrefixStable != tplNo || r.Model != "m1.gguf" || r.Tools != 2 || r.At.IsZero() {
t.Fatalf("verdict de la façade : %+v %v", r, ok)
}
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
if !strings.Contains(rec.Body.String(), `"prefix_stable":"no"`) {
t.Fatalf("résumé : %s", rec.Body.String())
}
// Aucun verdict encore, ou façade injoignable : rien.
verdict = `{"ok":false}`
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict inventé")
}
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict sans façade")
}
}
// Le panneau « moteur de l'image » part à la façade avec LOKI_ENGINE_IMAGE=rust
// (lot 24) ; la commande du dernier lancement reste au Go.
func TestFacadeMoteurImage(t *testing.T) {
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(facadeEngineImageEnv, "")
for p := range facadeEngineImageRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_ENGINE_IMAGE, %s : %q", p, w.Body.String())
}
}
if len(calls) != 0 {
t.Fatalf("appels à la façade sans LOKI_ENGINE_IMAGE : %v", calls)
}
t.Setenv(facadeEngineImageEnv, "Rust ")
if len(facadeEngineImageRoutes) != 7 {
t.Fatalf("routes : %v", facadeEngineImageRoutes)
}
for p := range facadeEngineImageRoutes {
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(facadeEngineImageRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil))
if w.Body.String() != "go" {
t.Fatalf("/api/llamacpp/install doit rester au Go : %q", w.Body.String())
}
// Sans jeton interne, rien ne part.
t.Setenv(internalTokenEnv, "")
if facadeEngineImage() {
t.Fatal("tenu sans jeton interne")
}
}
+26
View File
@@ -0,0 +1,26 @@
package loki
// facade_core.go — les routes de base que la façade sert toujours (api.rs,
// lot 35 de la migration) : santé, état, configuration, ligne de commande du
// moteur, mémoire vive et vidéo, journal du service, chemins, couches du
// modèle. Quand elles arrivent par le tunnel du relais (accès distant, E2E),
// le Go les lui remet aussi, comme les routes des lots : ses propres
// handlers ne servent plus que sans façade (LOKI_FACADE=0).
var facadeCoreRoutes = map[string]bool{
"/api/ping": true,
"/api/status": true,
"/api/config": true,
"/api/engine/cmdline": true,
"/api/model/layers": true,
"/api/paths": true,
"/api/ram": true,
"/api/vram": true,
"/api/service/log": true,
}
// facadeCore : une façade tourne devant ce processus (son adresse et le
// jeton interne sont là).
func facadeCore() bool {
return facadeAddr() != "" && internalToken() != ""
}
+49
View File
@@ -0,0 +1,49 @@
package loki
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Lot 35 : les routes de base arrivent à la façade par le tunnel du relais
// dès qu'elle tourne, sans variable ; sans façade (LOKI_FACADE=0 : ni
// adresse ni jeton), le Go les sert lui-même.
func TestFacadeRelaieLesRoutesDeBase(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeCoreRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if got := f.recu(); len(got) != len(facadeCoreRoutes) || !strings.HasSuffix(strings.TrimSpace(got[0]), "jeton") {
t.Fatalf("reçu par la façade : %v", got)
}
// Une route que le Go garde : ici.
w := httptest.NewRecorder()
withFacadeChat("/api/mem/health", local)(w, httptest.NewRequest("GET", "/api/mem/health", nil))
if w.Body.String() != "go" {
t.Fatalf("route du Go relayée : %q", w.Body.String())
}
// Façade muette : 502 qui le dit.
f.srv.Close()
w = httptest.NewRecorder()
withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil))
if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "façade indisponible : ") {
t.Fatalf("façade muette : %d %s", w.Code, w.Body.String())
}
// Sans façade : le Go sert.
t.Setenv(facadeAddrEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil))
if w.Body.String() != "go" {
t.Fatalf("sans façade : %q", w.Body.String())
}
}
+52
View File
@@ -0,0 +1,52 @@
package loki
import (
"fmt"
"os"
"strings"
)
// facade_dictate.go — dictée vocale tenue par la façade Rust (LOKI_DICTATE=rust,
// lot 9 de la migration ; expérimental, le Go reste le défaut).
//
// La façade sert elle-même /api/transcribe et /api/dictate/* sur le port
// public, et supervise le serveur de transcription. Deux choses restent à
// régler ici :
//
// - le tunnel du relais et sa redistribution E2E arrivent dans ce process :
// ces routes sont remises à la façade (withFacadeChat) ;
// - libérer la VRAM (web_vram.go) et l'optimiseur (backend_tune_run.go)
// arrêtent la dictée : c'est alors à la façade qu'on le demande, son
// serveur n'étant pas un enfant de ce process.
const facadeDictateEnv = "LOKI_DICTATE"
// facadeDictate : la façade tient-elle la dictée ? Il faut aussi son adresse
// et le jeton interne, sans lesquels rien ne peut lui être remis.
func facadeDictate() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDictateEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeDictateRoutes : les routes que la façade sert alors (dictate.rs).
var facadeDictateRoutes = map[string]bool{
"/api/transcribe": true,
"/api/dictate/config": true,
"/api/dictate/models": true,
"/api/dictate/models/download": true,
"/api/dictate/state": true,
}
// facadeDictateStop demande à la façade d'arrêter son serveur de dictée, sans
// attendre derrière un démarrage (elle tue le processus qui démarre). Rend
// false quand la façade ne tient pas la dictée : l'appelant arrête alors le
// serveur de ce process.
func facadeDictateStop() bool {
if !facadeDictate() {
return false
}
if err := facadePost("/internal/facade/v1/dictate/stop", map[string]any{}); err != nil {
fmt.Printf("[dictée] arrêt demandé à la façade : %v\n", err)
}
return true
}
+90
View File
@@ -0,0 +1,90 @@
package loki
import (
"net/http"
"os"
"strings"
)
// facade_engine.go — pilotage du moteur tenu par la façade Rust
// (LOKI_ENGINE=rust, lot 16 de la migration ; expérimental, le Go reste le
// défaut) : démarrage, arrêt, redémarrage (/api/start|stop|restart) et
// déchargement / rechargement de la VRAM (/api/vram/unload|reload).
//
// La façade lance elle-même « loki-rs engine <action> » — le superviseur que
// le Go appelait déjà en conteneur (sys_service_rust_linux.go) — et contrôle
// le moteur avant de le relancer (preflightEngine). Elle ne tient ce lot qu'en
// conteneur avec ce superviseur : sous systemd, ou avec
// LOKI_ENGINE_SUPERVISOR=go, le Go le garde (décidé par loki-rs au démarrage).
//
// Ce qui vit ici reste ici jusqu'au lot qui le portera, derrière l'API
// interne :
//
// - le verrou de l'optimiseur, consulté puis débarrassé d'un essai orphelin
// avant tout démarrage (/internal/v1/service/prestart) ;
// - la génération en cours quand le Go tient la conversation
// (/internal/v1/chat/generating) ;
// - l'arrêt de la dictée quand le Go la tient (/internal/v1/dictate/stop).
const facadeEngineEnv = "LOKI_ENGINE"
// facadeEngine : la façade pilote-t-elle le moteur ?
func facadeEngine() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeEngineRoutes : les routes que la façade sert alors (enginectl.rs).
var facadeEngineRoutes = map[string]bool{
"/api/start": true,
"/api/stop": true,
"/api/restart": true,
"/api/vram/unload": true,
"/api/vram/reload": true,
}
// enginePrestart : ce que serviceAction fait avant un démarrage — refus
// pendant une optimisation, puis ménage d'un verrou périmé (essai orphelin
// arrêté, application interrompue défaite). Variable pour les tests.
var enginePrestart = func() error {
if err := tuneGuard(); err != nil {
return err
}
tuneReapStale()
return nil
}
// handleInternalServicePrestart : POST → 200 {error} ("" : la voie est
// libre).
func handleInternalServicePrestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return
}
msg := ""
if err := enginePrestart(); err != nil {
msg = err.Error()
}
sendJSON(w, http.StatusOK, map[string]any{"error": msg})
}
// handleInternalChatGenerating : GET → 200 {generating} — la conversation
// tenue ici écrit-elle une réponse ?
func handleInternalChatGenerating(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
return
}
sendJSON(w, http.StatusOK, map[string]any{"generating": conv.isGenerating()})
}
// handleInternalDictateStop : POST → 204 — arrête la dictée tenue ici, sans
// attendre derrière un démarrage (asrShutdownVite).
func handleInternalDictateStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return
}
asrShutdownVite()
w.WriteHeader(http.StatusNoContent)
}
+34
View File
@@ -0,0 +1,34 @@
package loki
import (
"os"
"strings"
)
// facade_engineimg.go — panneau « moteur de l'image » tenu par la façade Rust
// (LOKI_ENGINE_IMAGE=rust, lot 24 de la migration ; expérimental, le Go reste
// le défaut) : version qui tourne et versions installées, dernier build publié
// sur le registre, mise à jour (job des moteurs, tenu par la façade avec
// LOKI_BACKENDS=rust), bascules, suppression, version recommandée, retour à la
// version précédente et vérification du rendu du gabarit
// (rust/loki-rs/src/engineimg). loki-rs ne passe la variable qu'avec les
// backends. /api/engine/cmdline reste ici.
const facadeEngineImageEnv = "LOKI_ENGINE_IMAGE"
// facadeEngineImage : la façade tient-elle le panneau ?
func facadeEngineImage() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineImageEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeEngineImageRoutes : les routes que la façade sert alors.
var facadeEngineImageRoutes = map[string]bool{
"/api/engine": true,
"/api/engine/check": true,
"/api/engine/update": true,
"/api/engine/use": true,
"/api/engine/remove": true,
"/api/engine/plan": true,
"/api/engine/rollback": true,
}
+67
View File
@@ -0,0 +1,67 @@
package loki
import (
"net/http"
"os"
"strings"
)
// facade_files.go — fichiers de la discussion tenus par la façade Rust
// (LOKI_FILES=rust, lot 11 de la migration ; expérimental, le Go reste le
// défaut) : dépôt des pièces jointes, panneau Fichiers, archive .zip,
// téléchargement et affichage des fichiers produits par l'agent.
//
// Le dossier de la discussion active reste décidé ICI (convWorkspace : la
// discussion active est connue de ce process, que la conversation soit tenue
// par lui ou par la façade) : la façade le demande à chaque requête par
// /internal/v1/conv/workspace. Les .part d'envois en cours sont les siens :
// ce process ne les vide plus au démarrage (la façade le fait au sien).
const facadeFilesEnv = "LOKI_FILES"
// facadeFiles : la façade tient-elle les fichiers de la discussion ?
func facadeFiles() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeFilesEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeFilesRoutes : les routes que la façade sert alors (convfiles.rs).
var facadeFilesRoutes = map[string]bool{
"/api/chat/upload": true,
"/api/chat/files": true,
"/api/chat/files/zip": true,
"/api/chat/file/delete": true,
"/api/chat/file": true,
"/api/chat/image": true,
}
// cleanStartupUploadParts vide les envois interrompus au démarrage — sauf quand
// la façade tient les fichiers : ce sont alors les siens.
func cleanStartupUploadParts() {
if !facadeFiles() {
cleanStaleUploadParts()
}
}
// handleInternalConvWorkspace : le dossier de la discussion active (créé au
// besoin), la racine du dossier de travail, et l'identité de la discussion
// (nom de l'archive .zip).
//
// POST /internal/v1/conv/workspace {} → {"dir","root","id","title"}
func handleInternalConvWorkspace(w http.ResponseWriter, r *http.Request) {
var in struct{}
if !readInternalJSON(w, r, &in) {
return
}
id := convEnsureActive()
title := ""
for _, m := range convIndex() {
if m.ID == id {
title = m.Title
break
}
}
sendJSON(w, http.StatusOK, map[string]any{
"dir": convWorkspace(), "root": agentWorkspace(), "id": id, "title": title,
})
}
+47
View File
@@ -0,0 +1,47 @@
package loki
import (
"net/http"
)
// facade_keepimg.go — KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32
// de la migration). L'état du tour (images à l'essai, mesure, budget) vit dans
// la façade ; ce qui touche aux fichiers et à la vision reste ici :
//
// - /internal/v1/turn/keepimg : la vision est-elle réellement active
// (visionEnabled, engineSeesImages) et le budget des images gardées
// (keepImgBudget) ;
// - /internal/v1/turn/keepimg/store : une image relayée rangée par
// référence (refRelayImage : fichier chiffré si la mémoire l'est, date
// rafraîchie, relu à l'octet près).
// handleInternalTurnKeepImg : POST {} → {"vision":bool,"budget":n}.
func handleInternalTurnKeepImg(w http.ResponseWriter, r *http.Request) {
var in struct{}
if !readInternalJSON(w, r, &in) {
return
}
sendJSON(w, http.StatusOK, map[string]any{
"vision": visionEnabled() && engineSeesImages(),
"budget": keepImgBudget(),
})
}
// handleInternalTurnKeepImgStore : POST {"msg":Message} →
// {"ok":true,"msg":Message,"weight":octets} ou {"ok":false} (format inconnu,
// disque plein, mémoire verrouillée, relecture différente : l'image reste
// éphémère).
func handleInternalTurnKeepImgStore(w http.ResponseWriter, r *http.Request) {
var in struct {
Msg Message `json:"msg"`
}
if !readInternalJSON(w, r, &in) {
return
}
m, weight, ok := refRelayImage(in.Msg)
if !ok {
sendJSON(w, http.StatusOK, map[string]any{"ok": false})
return
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "msg": m, "weight": weight})
}
+87
View File
@@ -0,0 +1,87 @@
package loki
import (
"encoding/json"
"os"
"strings"
"testing"
)
// L'état que la façade demande au début d'un tour : vision réellement active
// (projecteur déclaré ET vu par le moteur) et budget des images gardées.
func TestFacadeKeepImgEtat(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
if err := SetConfigKey("CTX", "2000"); err != nil {
t.Fatal(err)
}
etat := func() (bool, float64) {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg", `{}`)
var out struct {
Vision bool `json:"vision"`
Budget float64 `json:"budget"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
t.Fatalf("état : %d %s", w.Code, w.Body.String())
}
return out.Vision, out.Budget
}
if v, b := etat(); v || b != 200 {
t.Fatalf("sans projecteur : vision=%v budget=%v", v, b)
}
visionServer(t, "")
if v, _ := etat(); !v {
t.Fatal("projecteur vu par le moteur : vision attendue")
}
if w := appelOAIInterne(t, "GET", "/internal/v1/turn/keepimg", ""); w.Code != 405 {
t.Fatalf("GET : %d", w.Code)
}
}
// Une image relayée rangée par référence, relue à l'octet près ; sinon elle
// reste éphémère.
func TestFacadeKeepImgRangement(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
store := func(content string) map[string]any {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{"msg":{"role":"user","content":`+content+`}}`)
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
t.Fatalf("rangement : %d %s", w.Code, w.Body.String())
}
return out
}
out := store(`[{"type":"text","text":"Voici la capture demandée."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`)
if out["ok"] != true || out["weight"] != float64(8) {
t.Fatalf("rangée : %v", out)
}
msg := out["msg"].(map[string]any)
parts := msg["content"].([]any)
if parts[0].(map[string]any)["text"] != "Voici la capture demandée." {
t.Fatalf("légende : %v", parts)
}
ref := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string)
if !strings.HasPrefix(ref, imgRefScheme) {
t.Fatalf("pas par référence : %s", ref)
}
if back, ok := chatImageDataURL(ref); !ok || back != "data:image/png;base64,iVBORw0KGgo=" {
t.Fatalf("relecture : %v %.40s", ok, back)
}
if ents, _ := os.ReadDir(chatImgDir()); len(ents) != 1 {
t.Fatalf("%d fichier(s) rangé(s)", len(ents))
}
for _, c := range []string{
`"texte seul"`,
`[{"type":"image_url","image_url":{"url":"https://exemple.org/a.png"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/x-inconnu;base64,AAAA"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,pas du base64"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`,
} {
if out := store(c); out["ok"] != false {
t.Errorf("%s : %v", c, out)
}
}
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{`); w.Code != 400 {
t.Fatalf("corps illisible : %d", w.Code)
}
}
+106
View File
@@ -0,0 +1,106 @@
package loki
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// facade_mcp.go — serveurs MCP tenus par la façade Rust (LOKI_MCP=rust, lot 20
// de la migration ; expérimental, le Go reste le défaut) : configuration,
// catalogue, état et pool de sessions (rust/loki-rs/src/mcp).
//
// Un seul pool : celui de la façade. Le Go lui demande les outils MCP à
// annoncer au modèle, et leurs appels pour les tours qu'il exécute encore
// (sous-agents, tâches IA, discussion rendue au Go) via
// /internal/facade/v1/mcp/* ; son propre pool reste vide. Les tours de la
// façade appellent son pool sans passer par ici (lot 36).
const facadeMCPEnv = "LOKI_MCP"
// facadeMCP : la façade tient-elle les serveurs MCP ?
func facadeMCP() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMCPEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeMCPRoutes : les routes que la façade sert alors (mcp/mod.rs).
var facadeMCPRoutes = map[string]bool{
"/api/mcp": true,
"/api/mcp/catalog": true,
"/api/mcp/save": true,
"/api/mcp/delete": true,
"/api/mcp/toggle": true,
"/api/mcp/tool": true,
"/api/mcp/test": true,
}
// facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse.
// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une
// première connexion npx/uvx aussi (mcpConnectTimeoutCold).
func facadeMCPJSON(path string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// facadeMCPTools : les outils MCP à annoncer, tenus par la façade.
func facadeMCPTools() []Tool {
var out struct {
Tools []Tool `json:"tools"`
}
if err := facadeMCPJSON("/internal/facade/v1/mcp/tools", map[string]any{}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[mcp] outils demandés à la façade : %v\n", err)
return nil
}
return out.Tools
}
// facadeMCPCall : un outil MCP exécuté par la façade.
func facadeMCPCall(name string, args map[string]any) string {
if args == nil {
args = map[string]any{}
}
var out struct {
Result string `json:"result"`
}
if err := facadeMCPJSON("/internal/facade/v1/mcp/call", map[string]any{"name": name, "args": args}, &out); err != nil {
return "[erreur MCP] façade injoignable : " + err.Error()
}
return out.Result
}
// facadeMCPPrewarm : la façade ouvre ses sessions en tâche de fond.
func facadeMCPPrewarm() {
go func() {
var out map[string]any
// La façade démarre le Go puis attend qu'il réponde : elle peut ne pas
// écouter encore au tout premier instant.
for i := 0; i < 10; i++ {
if facadeMCPJSON("/internal/facade/v1/mcp/prewarm", map[string]any{}, &out) == nil {
return
}
time.Sleep(time.Second)
}
}()
}
+45
View File
@@ -0,0 +1,45 @@
package loki
import (
"os"
"strings"
)
// facade_mem.go — mémoire tenue par la façade Rust (LOKI_MEM=rust, lot 13 de
// la migration ; expérimental, le Go reste le défaut) : éditeur des pages,
// index MEMORY.md, déplacement d'une page vers un autre projet, panneau des
// trackers, mode mémoire et interrupteurs du mode agent.
//
// Les pages restent des fichiers memory/<projet>/*.md et les trackers des
// valeurs du bucket tracker : ce process continue de les lire et de les
// écrire pour sa propre boucle (outils mem_* et tracker, index injecté au
// modèle). Le chiffrement reste ici (la clé ne quitte pas ce process) : la
// façade fait sceller et ouvrir ses contenus par /internal/v1/crypt/*.
//
// Seules les routes changent de main ; celles qui arrivent ici par le tunnel
// du relais sont remises à la façade (withFacadeChat).
const facadeMemEnv = "LOKI_MEM"
// facadeMem : la façade tient-elle la mémoire ?
func facadeMem() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMemEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeMemRoutes : les routes que la façade sert alors (memory.rs).
var facadeMemRoutes = map[string]bool{
"/api/mem": true,
"/api/mem/save": true,
"/api/mem/delete": true,
"/api/mem/move": true,
"/api/agent": true,
"/api/tools": true,
"/api/skills": true,
"/api/agent/toggle": true,
"/api/tools/toggle": true,
"/api/skills/toggle": true,
"/api/agent/compact": true,
"/api/memory": true,
"/api/tracker": true,
}
+52
View File
@@ -0,0 +1,52 @@
package loki
import (
"os"
"strings"
)
// facade_models.go — routes des modèles tenues par la façade Rust
// (LOKI_MODELS=rust, lot 8 de la migration ; expérimental, le Go reste le
// défaut) : liste du sélecteur, dossiers, suppression, téléchargements et
// catalogue Hugging Face.
//
// La façade sert elle-même ces routes sur le port public. Le tunnel du relais
// et sa redistribution E2E arrivent pourtant ICI : elles lui sont remises, sans
// quoi un téléchargement lancé depuis le téléphone vivrait dans ce process, que
// le poste — servi par la façade — ne verrait pas.
//
// Les .part orphelins sont vidés par la façade à son démarrage : les vider ici
// (un Go relancé seul après un plantage) effacerait sous ses pieds un
// téléchargement qu'elle mène.
const facadeModelsEnv = "LOKI_MODELS"
// facadeModels : la façade tient-elle les routes des modèles ? Il faut aussi
// son adresse et le jeton interne, sans lesquels rien ne peut lui être remis.
func facadeModels() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeModelsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// cleanStartupPartFiles vide les .part orphelins au démarrage — sauf quand la
// façade tient les téléchargements : elle le fait à SON démarrage.
func cleanStartupPartFiles() {
if !facadeModels() {
cleanStalePartFiles()
}
}
// facadeModelRoutes : les routes que la façade sert alors (models.rs,
// downloads.rs, hf.rs).
var facadeModelRoutes = map[string]bool{
"/api/models": true,
"/api/models/delete": true,
"/api/models/dirs": true,
"/api/hf/search": true,
"/api/hf/files": true,
"/api/hf/token": true,
"/api/models/download": true,
"/api/models/download/probe": true,
"/api/models/download/status": true,
"/api/models/download/cancel": true,
}
+74
View File
@@ -0,0 +1,74 @@
package loki
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
// facade_perf.go — l'anneau de télémétrie tenu par la façade (lot 39 de la
// migration Rust). Quand elle tient la conversation, toutes les complétions y
// sont rangées :
//
// - celles de ses tours, directement ;
// - celles que le Go fait encore (sous-agents, tâches IA, bench, compaction
// et préchauffage de ses tours, clients externes de /v1), par
// /internal/facade/v1/perf/record.
//
// /api/perf/summary est servi par elle (facadeChatRoutes) et l'optimiseur lit
// l'anneau par perfSnapshot. Façade injoignable : la complétion est rangée
// ici, comme sans façade — la télémétrie ne fait jamais échouer un tour.
// facadePerfJSON appelle l'API de télémétrie de la façade et décode sa
// réponse.
func facadePerfJSON(path string, body, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("façade : %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// facadePerfRecord range une complétion dans l'anneau de la façade et la rend
// telle que rangée (numéro, perte de cache, ce qui s'est intercalé).
func facadePerfRecord(r perfRec, prefix []uint64) (perfRec, bool) {
var out struct {
Rec perfRec `json:"rec"`
}
err := facadePerfJSON("/internal/facade/v1/perf/record", map[string]any{"rec": r, "prefix": prefix}, &out)
if err != nil {
fmt.Fprintf(os.Stderr, "[perf] complétion non transmise à la façade : %v\n", err)
return perfRec{}, false
}
return out.Rec, true
}
// perfSnapshot : l'anneau, de la plus ancienne complétion à la plus récente —
// celui de la façade quand elle le tient.
func perfSnapshot() []perfRec {
if facadeChat() {
var out struct {
Recs []perfRec `json:"recs"`
}
if err := facadePerfJSON("/internal/facade/v1/perf/snapshot", map[string]any{}, &out); err == nil {
return out.Recs
}
}
return perfLog.snapshot()
}
+47
View File
@@ -0,0 +1,47 @@
package loki
import (
"strings"
"testing"
)
// Lot 39 : quand la façade tient la conversation, les complétions du Go vont
// dans son anneau, et l'optimiseur le lit ; façade en panne, l'anneau du Go
// prend le relais. /api/perf/summary lui est remis.
func TestFacadePerfAnneau(t *testing.T) {
testHome(t)
freshPerf(t)
f := demarrerFacade(t)
f.internalStatus = 200
f.internalBody = `{"rec":{"at":"2026-10-16T08:00:00Z","seq":7,"kind":"subagent","conv":"c1","iter":0,"complete":true,"total":10,"cached":2,"lost":3,"lost_after":"main"}}`
got := perfRecord(perfRec{Kind: perfSubagent, Conv: "c1", Complete: true, Total: 10, Cached: iptr(2)}, []uint64{1, 2})
if got.Seq != 7 || got.Lost == nil || *got.Lost != 3 || got.LostAfter != "main" {
t.Fatalf("rangée par la façade : %+v", got)
}
if n := len(perfLog.snapshot()); n != 0 {
t.Fatalf("rangée aussi ici : %d", n)
}
vu := f.recu()
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/perf/record jeton ") ||
!strings.Contains(vu[0], `"prefix":[1,2]`) || !strings.Contains(vu[0], `"kind":"subagent"`) {
t.Fatalf("envoi : %v", vu)
}
f.internalBody = `{"recs":[{"at":"2026-10-16T08:00:00Z","seq":1,"kind":"main","iter":0,"complete":true,"total":40}]}`
if s := perfSnapshot(); len(s) != 1 || s[0].Total != 40 {
t.Fatalf("anneau de la façade : %+v", s)
}
// Façade en panne : l'anneau d'ici.
f.internalStatus = 500
got = perfRecord(perfRec{Kind: perfForeign}, nil)
if got.Seq != 1 || got.Kind != perfForeign {
t.Fatalf("repli : %+v", got)
}
if s := perfSnapshot(); len(s) != 1 || s[0].Kind != perfForeign {
t.Fatalf("repli de l'anneau : %+v", s)
}
if !facadeChatRoutes["/api/perf/summary"] {
t.Fatal("/api/perf/summary n'est pas remis à la façade")
}
}
+136
View File
@@ -0,0 +1,136 @@
package loki
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// facade_presets.go — presets tenus par la façade Rust (LOKI_PRESETS=rust,
// lot 15 de la migration ; expérimental, le Go reste le défaut) : liste,
// ordre, lecture, éditeur, suppression, aperçus de l'éditeur, copie en
// placement auto, presets externes, bascule.
//
// Les presets restent des fichiers presets/*.env et leur prompt système une
// clé de la base. Ce qui touche au moteur reste ici jusqu'au lot qui le
// portera : verrou de l'optimiseur (/internal/v1/tune/guard) et application
// d'un preset à la configuration vivante, avec l'état du slot gardé et le
// moteur relancé ou arrêté (/internal/v1/preset/apply).
const facadePresetsEnv = "LOKI_PRESETS"
// facadePresets : la façade tient-elle les presets ?
func facadePresets() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePresetsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadePresetsRoutes : les routes que la façade sert alors (presets_api.rs).
var facadePresetsRoutes = map[string]bool{
"/api/presets": true,
"/api/presets/order": true,
"/api/preset": true,
"/api/preset/save": true,
"/api/preset/cacheram": true,
"/api/preset/moe": true,
"/api/preset/autoplace": true,
"/api/preset/delete": true,
"/api/preset/external": true,
"/api/preset/external/save": true,
"/api/preset/external/test": true,
"/api/switch": true,
}
// presetServiceAction : le pilotage du moteur après une application de
// preset. Variable pour les tests (aucun moteur n'y tourne).
var presetServiceAction = serviceAction
// presetApply applique un preset (chemin dans presets/) à la configuration
// vivante, comme les handlers d'origine :
//
// switch : bascule (handleSwitch) — état du slot relevé avant, moteur arrêté
// pour un preset externe, relancé sinon, en arrière-plan ;
// save : preset en service modifié (handlePresetSave) — relancé ;
// apply : preset externe en service modifié (handlePresetExternalSave) —
// configuration seule.
func presetApply(path, mode string) error {
root, err := filepath.Abs(presetsDir())
if err != nil {
return err
}
abs, err := filepath.Abs(path)
if err != nil || !strings.HasPrefix(abs, root+string(filepath.Separator)) || !strings.HasSuffix(abs, ".env") {
return fmt.Errorf("preset hors du dossier des presets")
}
switch mode {
case "switch":
persist := slotPersistPrepare()
if err := applyPresetFile(abs); err != nil {
return err
}
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(abs))
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
persist.save()
if err := presetServiceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
}
}
}()
return nil
}
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
persist.save()
if err := presetServiceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
}
}()
return nil
case "save":
if err := applyPresetFile(abs); err != nil {
return err
}
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), filepath.Base(abs))
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
if err := presetServiceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après modification du preset: %v\n", red("[ERREUR]"), err)
}
}()
return nil
case "apply":
return applyPresetFile(abs)
}
return fmt.Errorf("mode inconnu : %q", mode)
}
// handleInternalPresetApply : POST {path, mode} → 204, ou 400/500 + {error}.
func handleInternalPresetApply(w http.ResponseWriter, r *http.Request) {
var in struct {
Path string `json:"path"`
Mode string `json:"mode"`
}
if !readInternalJSON(w, r, &in) {
return
}
if err := presetApply(in.Path, in.Mode); err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleInternalTuneGuard : GET → {busy, error} — une optimisation tient-elle
// le moteur (tuneGuard) ?
func handleInternalTuneGuard(w http.ResponseWriter, r *http.Request) {
if err := tuneGuard(); err != nil {
sendJSON(w, http.StatusOK, map[string]any{"busy": true, "error": err.Error()})
return
}
sendJSON(w, http.StatusOK, map[string]any{"busy": false})
}
+150
View File
@@ -0,0 +1,150 @@
package loki
import (
"net/http"
"sync"
)
// facade_prewarm.go — PREWARM quand la façade tient la conversation (lot 33 de
// la migration). La façade assemble la requête du prochain tour (elle tient
// l'historique) et l'envoie ; le Go garde ce qui décide si elle peut partir
// et ce qui l'annule :
//
// - /internal/v1/prewarm/check : le moteur est-il libre d'un vrai travail
// (travail annexe, bench, optimiseur), loin de minuit, en bonne santé, et
// sur quel slot viser (un seul slot, ou celui de la discussion avec
// SIDE_SLOT) ;
// - /internal/v1/prewarm/begin : recharge l'état SLOT_PERSIST de la
// discussion, puis inscrit le préchauffage comme LA requête en vol
// (prewarmBegin). Toute requête de Loki qui part ensuite l'annule — sauf
// un tour de la façade qui le prolonge (/internal/v1/oai/begin,
// prewarm_keep) : l'annulation est relayée à la façade, qui coupe sa
// connexion au moteur ;
// - /internal/v1/prewarm/end : la fin, avec ce que le moteur a repris du
// cache (noteEnginePrompt) ;
// - après une tâche planifiée, le déclencheur part à la façade
// (prewarmKick).
// Préchauffages de la façade inscrits ici : id → fin.
var facadePrewarms = struct {
mu sync.Mutex
next uint64
m map[uint64]func()
}{m: map[uint64]func(){}}
// handleInternalPrewarmCheck : POST {} → {"ok":true,"slot":-1|0} ou
// {"ok":false,"why":"…"}.
func handleInternalPrewarmCheck(w http.ResponseWriter, r *http.Request) {
var in struct{}
if !readInternalJSON(w, r, &in) {
return
}
refuse := func(why string) {
sendJSON(w, http.StatusOK, map[string]any{"ok": false, "why": why})
}
if prewarmMode(ReadConfig()) == "" {
refuse("clé absente")
return
}
ep := resolveChatEndpoint()
if ep.External {
refuse("preset externe")
return
}
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil {
refuse("moteur occupé")
return
}
if prewarmNearMidnight(prewarmNow()) {
refuse("minuit proche")
return
}
if !healthCheck() {
refuse("moteur indisponible")
return
}
// Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le
// slot de la discussion (id_slot 0), celui où partira le vrai tour.
slot := -1
if n := prewarmSlots(ep); n != 1 {
if slot = engineSlotFor(ep, true); n != 2 || slot != 0 {
refuse("plusieurs slots")
return
}
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "slot": slot})
}
// handleInternalPrewarmBegin : POST {"conv","n","prefix","opts"} →
// {"ok":true,"id":n} ou {"ok":false} (une requête de Loki est en vol, ou un
// autre préchauffage). n : messages du préfixe préchauffé (sans la
// sentinelle) ; prefix : son empreinte cumulée (perfPrefix) ; opts : celle
// des champs qui façonnent le rendu.
func handleInternalPrewarmBegin(w http.ResponseWriter, r *http.Request) {
var in struct {
Conv string `json:"conv"`
N int `json:"n"`
Prefix uint64 `json:"prefix"`
Opts uint64 `json:"opts"`
}
if !readInternalJSON(w, r, &in) {
return
}
// SLOT_PERSIST : l'état gardé de la discussion d'abord — le préchauffage
// le prolonge au lieu de tout recalculer.
slotPersistRestore(resolveChatEndpoint(), in.Conv)
facadePrewarms.mu.Lock()
facadePrewarms.next++
id := facadePrewarms.next
facadePrewarms.mu.Unlock()
run := &prewarmRun{n: in.N, prefix: in.Prefix, opts: in.Opts, done: make(chan struct{})}
// Annulé par une autre requête : la façade coupe sa connexion.
run.cancel = func() {
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm/cancel", map[string]any{"id": id}) }()
}
end, ok := prewarmBegin(run)
if !ok {
sendJSON(w, http.StatusOK, map[string]any{"ok": false})
return
}
facadePrewarms.mu.Lock()
facadePrewarms.m[id] = func() {
end()
close(run.done)
}
facadePrewarms.mu.Unlock()
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
}
// handleInternalPrewarmEnd : POST {"id","total","cached"} → 204. total : la
// taille du prompt préchauffé (0 : inconnue) ; cached : ce que le moteur a
// repris de son cache (absent : inconnu).
func handleInternalPrewarmEnd(w http.ResponseWriter, r *http.Request) {
var in struct {
ID uint64 `json:"id"`
Total int `json:"total"`
Cached *int `json:"cached"`
}
if !readInternalJSON(w, r, &in) {
return
}
facadePrewarms.mu.Lock()
end := facadePrewarms.m[in.ID]
delete(facadePrewarms.m, in.ID)
facadePrewarms.mu.Unlock()
if end == nil {
sendJSON(w, http.StatusNotFound, map[string]any{"error": "préchauffage inconnu"})
return
}
end()
// Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le
// cache RAM : la vérification de llm_slots.go se fait ici.
if in.Total > 0 {
cached := 0
if in.Cached != nil {
cached = *in.Cached
}
noteEnginePrompt(in.Total, cached, in.Cached != nil)
}
w.WriteHeader(http.StatusNoContent)
}
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// moteurSain : un llama-server de test qui répond à /health.
func moteurSain(t *testing.T) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func prewarmPost(t *testing.T, path, body string) (int, map[string]any) {
t.Helper()
w := appelOAIInterne(t, "POST", path, body)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// Le moteur est-il libre d'un vrai travail, et sur quel slot viser ?
func TestFacadePrewarmCheck(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
check := func() map[string]any {
_, out := prewarmPost(t, "/internal/v1/prewarm/check", `{}`)
return out
}
if out := check(); out["ok"] != false || out["why"] != "clé absente" {
t.Fatalf("sans la clé : %v", out)
}
if err := SetConfigKey("PREWARM", "on"); err != nil {
t.Fatal(err)
}
moteurSain(t)
prevSlots, prevNow := prewarmSlots, prewarmNow
t.Cleanup(func() { prewarmSlots, prewarmNow = prevSlots, prevNow })
prewarmSlots = func(chatEndpoint) int { return 1 }
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) }
if out := check(); out["ok"] != true || out["slot"] != float64(-1) {
t.Fatalf("un seul slot : %v", out)
}
prewarmSlots = func(chatEndpoint) int { return 4 }
if out := check(); out["ok"] != false || out["why"] != "plusieurs slots" {
t.Fatalf("quatre slots : %v", out)
}
prewarmSlots = func(chatEndpoint) int { return 1 }
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 23, 55, 0, 0, time.Local) }
if out := check(); out["why"] != "minuit proche" {
t.Fatalf("minuit : %v", out)
}
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) }
sideJobs.Add(1)
out := check()
sideJobs.Add(-1)
if out["why"] != "moteur occupé" {
t.Fatalf("travail annexe : %v", out)
}
if err := SetConfigKey("PORT", "1"); err != nil {
t.Fatal(err)
}
if out := check(); out["why"] != "moteur indisponible" {
t.Fatalf("moteur muet : %v", out)
}
}
// Inscrit comme LA requête en vol ; annulé par toute autre requête (la
// façade l'apprend), sauf un tour qui le prolonge ; rendu à la fin.
func TestFacadePrewarmEnVol(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
begin := func() (bool, float64) {
_, out := prewarmPost(t, "/internal/v1/prewarm/begin", `{"conv":"c1","n":3,"prefix":11,"opts":22}`)
id, _ := out["id"].(float64)
return out["ok"] == true, id
}
ok, id := begin()
if !ok || id == 0 || !prewarmLive.Load() || enVol() != 1 {
t.Fatalf("inscription : ok=%v id=%v en vol=%d", ok, id, enVol())
}
if ok, _ := begin(); ok {
t.Fatal("deux préchauffages en vol")
}
// Un tour de la façade qui le prolonge : gardé.
w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1","prewarm_keep":true}`)
var tk struct {
Ticket uint64 `json:"ticket"`
}
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if !prewarmLive.Load() {
t.Fatal("préchauffage annulé par le tour qui le prolonge")
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
// Toute autre requête : annulé, la façade coupe sa connexion.
w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`)
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if prewarmLive.Load() {
t.Fatal("préchauffage gardé malgré une autre requête")
}
deadline := time.Now().Add(2 * time.Second)
want := fmt.Sprintf(`POST /internal/facade/v1/chat/prewarm/cancel jeton {"id":%d}`, int(id))
for !recuContient(f.recu(), want) {
if time.Now().After(deadline) {
t.Fatalf("annulation non relayée : %v", f.recu())
}
time.Sleep(10 * time.Millisecond)
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
// La fin rend la place, une seule fois.
if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d,"total":900,"cached":800}`, int(id))); code != 204 {
t.Fatalf("fin : %d", code)
}
if enVol() != 0 {
t.Fatalf("encore %d requête(s) en vol", enVol())
}
if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d}`, int(id))); code != 404 {
t.Fatalf("fin répétée : %d", code)
}
// Une requête déjà en vol : rien ne part.
w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`)
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if ok, _ := begin(); ok {
t.Fatal("préchauffage inscrit malgré une requête en vol")
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
}
// Après une tâche planifiée, le déclencheur part à la façade.
func TestFacadePrewarmApresTache(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
if err := SetConfigKey("PREWARM", "on"); err != nil {
t.Fatal(err)
}
conv.prewarmKick(prewarmAfterTask)
deadline := time.Now().Add(2 * time.Second)
want := `POST /internal/facade/v1/chat/prewarm jeton {"why":"après-tâche"}`
for !recuContient(f.recu(), want) {
if time.Now().After(deadline) {
t.Fatalf("déclencheur non relayé : %v", f.recu())
}
time.Sleep(10 * time.Millisecond)
}
}
func recuContient(l []string, s string) bool {
for _, x := range l {
if strings.TrimSpace(x) == s {
return true
}
}
return false
}
+77
View File
@@ -0,0 +1,77 @@
package loki
import (
"net/http"
"os"
"strings"
)
// facade_projects.go — hub des projets tenu par la façade Rust
// (LOKI_PROJECTS=rust, lot 18 de la migration ; expérimental, le Go reste le
// défaut) : liste avec ses comptes, création, renommage, description,
// suppression, bascule (/api/projects).
//
// Le registre reste dans la base (state/projects) et la mémoire de chaque
// projet dans memory/<slug>. Le projet actif passe toujours par le Go
// (/internal/v1/project/activate), qui en garde une copie en RAM. Quand le Go
// tient la conversation, ce qui la touche reste ici :
//
// - la bascule : discussion du projet quitté enregistrée, la plus récente
// du projet rejoint ouverte (/internal/v1/project/switch) ;
// - les discussions d'un projet supprimé (/internal/v1/project/drop-convs).
//
// Quand la façade tient la conversation, elle fait les deux elle-même.
const facadeProjectsEnv = "LOKI_PROJECTS"
// facadeProjects : la façade tient-elle le hub des projets ?
func facadeProjects() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeProjectsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeProjectsRoutes : les routes que la façade sert alors (projects_api.rs).
var facadeProjectsRoutes = map[string]bool{
"/api/projects": true,
}
// handleInternalProjectSwitch : POST {slug} → 204, ou 400/409 + {error}.
func handleInternalProjectSwitch(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
}
if !readInternalJSON(w, r, &in) {
return
}
if conv.isGenerating() {
sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"})
return
}
if err := projectSwitch(strings.TrimSpace(in.Slug)); err != nil {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleInternalProjectDropConvs : POST {slug} → 200 {dropped} — supprime les
// discussions du projet, comme deleteProject quand le Go tient la conversation.
func handleInternalProjectDropConvs(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
}
if !readInternalJSON(w, r, &in) {
return
}
if conv.isGenerating() {
sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"})
return
}
n := 0
for _, m := range convIndexForProject(strings.TrimSpace(in.Slug)) {
if convDelete(m.ID) == nil {
n++
}
}
sendJSON(w, http.StatusOK, map[string]any{"dropped": n})
}
+94
View File
@@ -0,0 +1,94 @@
package loki
import (
"net/http"
)
// facade_projsnap.go — PROJ_SNAPSHOT pour le tour tenu par la façade (lot 34
// de la migration). La façade range l'instantané avec la discussion (il est
// persisté avec elle, champ proj_snap) et pose les <context_update> dans son
// historique ; le Go, qui lit le contexte du projet (description, index
// mémoire, trackers, consignes du dépôt, date et dossier de travail), décide :
//
// - /internal/v1/turn/view, avec la clé et l'agent : la tête du tour porte
// le bloc figé (ou le vivant s'il faut rafraîchir), et projsnap dit quoi
// faire de la discussion — rafraîchir (instantané neuf, blocs retirés de
// l'historique) ou poser une mise à jour en tête du dernier message
// utilisateur. dry : la même décision pour le préchauffage, sans rien
// rendre à ranger ;
// - /internal/v1/turn/projsnap/refresh : une compaction ou une réduction
// réécrit l'historique en plein tour, le bloc vivant remplace le figé et
// devient l'instantané du tour suivant.
// projSnapView : les pièces de turnView avec la clé (assembleTurn, chemin
// PROJ_SNAPSHOT). snap : l'instantané de la discussion (nil : aucun).
func projSnapView(caps Caps, snap *projSnapshot, dry bool) map[string]any {
cfg := ReadConfig()
// Date et dossier de travail : dans le bloc, plus dans le système.
caps.envInCtx = true
asOf := projSnapNow()
live := projSnapCapture(caps, asOf)
key := projSnapKey(caps, cfg)
tools := EnabledTools(caps)
if tools == nil {
tools = []Tool{}
}
head := []Message{}
if sp := readSysPrompt(); sp != "" {
head = append(head, Message{Role: "system", Content: sp})
}
sysHash := projSnapSysHash(InjectSkills(append([]Message(nil), head...), caps, tools), tools)
refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf)
block := live.msgs
if !refresh {
block = snap.Msgs
}
out := map[string]any{"refresh": refresh, "key": key, "sys_hash": sysHash}
if !dry {
switch {
case refresh:
out["snap"] = newProjSnapshot(live, key, sysHash)
case delta != "":
// Rangé par la façade seulement si la mise à jour a trouvé un
// message utilisateur où se poser (projSnapApplyLocked).
next := *snap
next.State = live.state
next.DeltaTok += tok
out["snap"] = &next
out["delta"] = delta
default:
out["snap"] = snap
}
}
return map[string]any{
"head": append(head, block...),
"prefix": injectPrefix(caps, tools),
"tools": tools,
"projsnap": out,
}
}
// handleInternalTurnProjSnapRefresh : POST {"caps","key","sys_hash"} →
// {"block":[…],"snap":{…}} — le bloc vivant et l'instantané qu'il devient
// (projSnapTurn.refresh).
func handleInternalTurnProjSnapRefresh(w http.ResponseWriter, r *http.Request) {
var in struct {
Caps Caps `json:"caps"`
Key string `json:"key"`
SysHash string `json:"sys_hash"`
}
if !readInternalJSON(w, r, &in) {
return
}
caps := in.Caps
caps.envInCtx = true
live := projSnapCapture(caps, projSnapNow())
block := live.msgs
if block == nil {
block = []Message{}
}
sendJSON(w, http.StatusOK, map[string]any{
"block": block,
"snap": newProjSnapshot(live, in.Key, in.SysHash),
})
}
+169
View File
@@ -0,0 +1,169 @@
package loki
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// projSnapFacade : /internal/v1/turn/view avec la clé, décodé.
type projSnapFacadeView struct {
Head []Message `json:"head"`
Prefix string `json:"prefix"`
Tools []Tool `json:"tools"`
ProjSnap struct {
Refresh bool `json:"refresh"`
Delta string `json:"delta"`
Snap *projSnapshot `json:"snap"`
Key string `json:"key"`
SysHash string `json:"sys_hash"`
} `json:"projsnap"`
}
func projSnapFacade(t *testing.T, caps Caps, snap *projSnapshot, dry bool) projSnapFacadeView {
t.Helper()
body, _ := json.Marshal(map[string]any{"caps": caps, "snap": snap, "dry": dry})
w := appelOAIInterne(t, "POST", "/internal/v1/turn/view", string(body))
if w.Code != 200 {
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
}
var v projSnapFacadeView
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
return v
}
// msgsJSON : la séquence telle qu'elle part (forme JSON, sans les champs
// propres au Go).
func msgsJSON(t *testing.T, msgs []Message) string {
t.Helper()
b, err := json.Marshal(msgs)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// La façade assemble ce que turnView aurait envoyé : instantané pris au
// premier tour, mise à jour posée en tête du message suivant après un
// changement, l'instantané de la façade rangé et relu.
func TestFacadeProjSnapCommeTurnView(t *testing.T) {
projSnapSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
caps := Caps{Agent: true, Mem: MemAlways}
// Premier tour : rien de figé, instantané neuf.
v := projSnapFacade(t, caps, nil, false)
if !v.ProjSnap.Refresh || v.ProjSnap.Snap == nil || v.ProjSnap.Delta != "" {
t.Fatalf("premier tour : %+v", v.ProjSnap)
}
c := newTestConv()
msgs := []Message{um("bonjour")}
c.Messages = append([]Message(nil), msgs...)
sent, tools, _ := c.turnView(caps, c.epoch, msgs, true)
got := withPrefix(append(append([]Message(nil), v.Head...), msgs...), v.Prefix)
if msgsJSON(t, got) != msgsJSON(t, sent) {
t.Fatalf("séquence différente de turnView :\n%s\n%s", msgsJSON(t, got), msgsJSON(t, sent))
}
if !reflect.DeepEqual(jsonValue(t, v.Tools), jsonValue(t, tools)) {
t.Fatal("outils différents de turnView")
}
if !reflect.DeepEqual(v.ProjSnap.Snap.State, c.ProjSnap.State) || v.ProjSnap.Snap.Key != c.ProjSnap.Key || v.ProjSnap.Snap.SysHash != c.ProjSnap.SysHash {
t.Fatalf("instantané différent :\n%+v\n%+v", v.ProjSnap.Snap, c.ProjSnap)
}
if v.ProjSnap.Key != c.ProjSnap.Key || v.ProjSnap.SysHash != c.ProjSnap.SysHash {
t.Fatal("clé du tour différente")
}
// Une page ajoutée : bloc figé gardé, mise à jour annoncée.
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
snap := v.ProjSnap.Snap
v2 := projSnapFacade(t, caps, snap, false)
if v2.ProjSnap.Refresh || !strings.Contains(v2.ProjSnap.Delta, "](nas.md)") {
t.Fatalf("mise à jour attendue : %+v", v2.ProjSnap)
}
if msgsJSON(t, v2.Head) != msgsJSON(t, v.Head) {
t.Fatal("bloc figé non gardé")
}
c.Messages = append(c.Messages, am("ok."), um("et le NAS ?"))
msgs2 := append([]Message(nil), c.Messages...)
sent2, _, _ := c.turnView(caps, c.epoch, msgs2, true)
last := msgText(sent2[len(sent2)-1])
if last != v2.ProjSnap.Delta+"et le NAS ?" {
t.Fatalf("mise à jour différente de turnView :\n%q\n%q", last, v2.ProjSnap.Delta+"et le NAS ?")
}
next := v2.ProjSnap.Snap
if next == nil || next.State.Mem["nas.md"] == "" || next.DeltaTok <= snap.DeltaTok || !reflect.DeepEqual(next.State, c.ProjSnap.State) {
t.Fatalf("état annoncé non suivi : %+v", next)
}
// Sans nouveau changement : rien à poser, instantané inchangé.
v3 := projSnapFacade(t, caps, next, false)
if v3.ProjSnap.Refresh || v3.ProjSnap.Delta != "" || !reflect.DeepEqual(v3.ProjSnap.Snap.State, next.State) {
t.Fatalf("tour sans changement : %+v", v3.ProjSnap)
}
// dry (préchauffage) : la même décision, rien à ranger.
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
d := projSnapFacade(t, caps, next, true)
if d.ProjSnap.Refresh || d.ProjSnap.Snap != nil || d.ProjSnap.Delta != "" || msgsJSON(t, d.Head) != msgsJSON(t, v.Head) {
t.Fatalf("préchauffage : %+v", d.ProjSnap)
}
// Instantané d'un autre lancement (clé) : rafraîchi.
stale := *next
stale.Key = "autre"
if r := projSnapFacade(t, caps, &stale, false); !r.ProjSnap.Refresh {
t.Fatal("clé changée : rafraîchissement attendu")
}
if r := projSnapFacade(t, caps, &stale, true); !r.ProjSnap.Refresh || msgsJSON(t, r.Head) == msgsJSON(t, v.Head) {
t.Fatal("préchauffage d'un instantané périmé : bloc vivant attendu")
}
}
// Compaction en plein tour : le bloc vivant, et l'instantané qu'il devient
// avec la clé et le système du tour.
func TestFacadeProjSnapRafraichi(t *testing.T) {
projSnapSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{"caps":{"Agent":true,"Mem":"always"},"key":"k1","sys_hash":"s1"}`)
if w.Code != 200 {
t.Fatalf("rafraîchissement : %d %s", w.Code, w.Body.String())
}
var out struct {
Block []Message `json:"block"`
Snap *projSnapshot `json:"snap"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
live := projSnapCapture(Caps{Agent: true, Mem: MemAlways, envInCtx: true}, projSnapNow())
if msgsJSON(t, out.Block) != msgsJSON(t, live.msgs) || len(out.Block) == 0 {
t.Fatalf("bloc vivant différent : %s", msgsJSON(t, out.Block))
}
if out.Snap == nil || out.Snap.Key != "k1" || out.Snap.SysHash != "s1" || out.Snap.State.Mem["nas.md"] == "" {
t.Fatalf("instantané : %+v", out.Snap)
}
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{`); w.Code != 400 {
t.Fatalf("corps illisible : %d", w.Code)
}
}
// withPrefix : ce que la façade fait du préambule rendu (inject_prefix,
// comme InjectSkills avec ce texte).
func withPrefix(msgs []Message, prefix string) []Message {
if prefix == "" {
return msgs
}
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
existing, _ := msgs[0].Content.(string)
return append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
}
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
}
+43
View File
@@ -0,0 +1,43 @@
package loki
import (
"fmt"
"os"
"strings"
)
// facade_push.go — notifications Web Push tenues par la façade Rust
// (LOKI_PUSH=rust, lot 12 de la migration ; expérimental, le Go reste le
// défaut).
//
// La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
// les notifications : les envois de ce process (fin de tour, fin de tâche) lui
// sont délégués par /internal/facade/v1/push/send. Clés et abonnements restent
// en base aux mêmes clés : hasPushSubs les lit toujours ici.
const facadePushEnv = "LOKI_PUSH"
// facadePush : la façade tient-elle les notifications ?
func facadePush() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePushEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadePushRoutes : les routes que la façade sert alors (push.rs).
var facadePushRoutes = map[string]bool{
"/api/push/key": true,
"/api/push/subscribe": true,
"/api/push/unsubscribe": true,
}
// facadePushSend délègue un envoi à la façade ; false quand elle ne tient pas
// les notifications (l'appelant envoie alors lui-même).
func facadePushSend(title, body string) bool {
if !facadePush() {
return false
}
if err := facadePost("/internal/facade/v1/push/send", map[string]any{"title": title, "body": body}); err != nil {
fmt.Printf("[push] envoi délégué à la façade : %v\n", err)
}
return true
}
+32
View File
@@ -0,0 +1,32 @@
package loki
import (
"os"
"strings"
)
// facade_settings.go — réglages partagés tenus par la façade Rust
// (LOKI_SETTINGS=rust, lot 10 de la migration ; expérimental, le Go reste le
// défaut) : préférences d'affichage, prompt système, intensité du
// raisonnement, adresse publique.
//
// Les valeurs restent dans la base, aux mêmes clés : ce process les relit là
// où il s'en sert (tour de chat, accès OpenAI). Seules les routes changent de
// main ; celles qui arrivent ici par le tunnel du relais sont remises à la
// façade (withFacadeChat).
const facadeSettingsEnv = "LOKI_SETTINGS"
// facadeSettings : la façade tient-elle les réglages partagés ?
func facadeSettings() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSettingsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeSettingsRoutes : les routes que la façade sert alors (settings.rs).
var facadeSettingsRoutes = map[string]bool{
"/api/prefs": true,
"/api/sysprompt": true,
"/api/reasoning-effort": true,
"/api/oai/public": true,
}
+39
View File
@@ -0,0 +1,39 @@
package loki
import (
"net/http"
)
// facade_slots.go — les slots du moteur pour le tour tenu par la façade (lot 31
// de la migration). La machinerie reste ici, où vivent la configuration, le
// compteur des requêtes en vol et le tampon du slot de la discussion
// (llm_slots.go) ; la façade demande, avant chaque requête au moteur local,
// le slot où l'envoyer.
//
// - SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0, le
// reste (vérification, résumé sur transcription) sur le 1, tant que le
// second slot est en service ; sinon aucun id_slot.
// - SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule de
// preset est rechargé avant la première requête de la discussion, si tout
// concorde. La sauvegarde, elle, se fait à la bascule (déjà ici) : le slot
// porte la discussion dès que le moteur a accepté une étape de son tour
// (/internal/v1/oai/served, engineMarkMain).
// handleInternalTurnSlot : POST {"conv":"…","main":true} → {"slot":0|1|-1}.
// main : requête du fil de la discussion (étape d'un tour, résumé en
// continuation) ; conv : la discussion d'une étape de tour, pour le
// rechargement SLOT_PERSIST — vide pour un résumé. -1 : aucun id_slot.
func handleInternalTurnSlot(w http.ResponseWriter, r *http.Request) {
var in struct {
Conv string `json:"conv"`
Main bool `json:"main"`
}
if !readInternalJSON(w, r, &in) {
return
}
ep := resolveChatEndpoint()
if in.Main && in.Conv != "" {
slotPersistRestore(ep, in.Conv)
}
sendJSON(w, http.StatusOK, map[string]any{"slot": engineSlotFor(ep, in.Main)})
}
+129
View File
@@ -0,0 +1,129 @@
package loki
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
// Le slot d'une requête de la façade : aucun sans la clé (et /props jamais
// lu), le fil de la discussion sur le 0 et le reste sur le 1 quand le second
// slot est en service.
func TestFacadeTurnSlot(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
slot := func(body string) (int, float64) {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", body)
var out struct {
Slot float64 `json:"slot"`
}
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out.Slot
}
prev := sideSlotProps
sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false }
for _, b := range []string{`{"conv":"c1","main":true}`, `{"main":false}`} {
if code, s := slot(b); code != 200 || s != -1 {
t.Fatalf("sans la clé %s : %d %v", b, code, s)
}
}
sideSlotProps = prev
if err := SetConfigKey("SIDE_SLOT", "on"); err != nil {
t.Fatal(err)
}
if err := SetConfigKey("CTX", "8192"); err != nil {
t.Fatal(err)
}
stubSideProps(t, 2, 8192)
if _, s := slot(`{"conv":"c1","main":true}`); s != 0 {
t.Fatalf("étape du tour : slot %v", s)
}
if _, s := slot(`{"main":false}`); s != 1 {
t.Fatalf("vérification : slot %v", s)
}
if _, s := slot(`{"main":true}`); s != 0 {
t.Fatalf("résumé en continuation : slot %v", s)
}
// Second slot hors service : plus d'id_slot.
stubSideProps(t, 1, 8192)
if _, s := slot(`{"main":false}`); s != -1 {
t.Fatalf("un seul slot : %v", s)
}
if code, _ := slot(`{`); code != 400 {
t.Fatalf("corps illisible : %d", code)
}
if w := appelOAIInterne(t, "GET", "/internal/v1/turn/slot", ""); w.Code != 405 {
t.Fatalf("GET : %d", w.Code)
}
}
// SLOT_PERSIST : l'état gardé à la bascule est rechargé avant la première
// étape du tour de CETTE discussion, une seule fois ; jamais pour une autre
// discussion, une requête annexe ou un résumé.
func TestFacadeTurnSlotRecharge(t *testing.T) {
m, home := persistSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("rien à garder")
}
snap.save()
name := slotPersistName(persistTestKey, "c1")
for _, b := range []string{
`{"conv":"autre","main":true}`,
`{"conv":"c1","main":false}`,
`{"main":true}`,
`{"conv":"c1","main":true}`,
`{"conv":"c1","main":true}`,
} {
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", b); w.Code != 200 {
t.Fatalf("%s : %d", b, w.Code)
}
}
want := []string{"save tmp-" + name, "restore " + name}
if got := m.got(); !reflect.DeepEqual(got, want) {
t.Fatalf("appels %v, attendu %v", got, want)
}
if _, err := os.Stat(filepath.Join(home, "slots", name)); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
}
// Une étape de tour de la façade acceptée par le moteur marque le slot :
// c'est ce que la sauvegarde SLOT_PERSIST relit à la bascule.
func TestFacadeTurnSlotSauvegarde(t *testing.T) {
m, home := persistSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
engineGate.mu.Lock()
engineGate.main = engineMainStamp{}
engineGate.mu.Unlock()
if slotPersistPrepare() != nil {
t.Fatal("slot sans discussion : rien à garder")
}
w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c2"}`)
var b struct {
Ticket uint64 `json:"ticket"`
}
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || b.Ticket == 0 {
t.Fatalf("begin : %s", w.Body.String())
}
tk := `{"ticket":` + jsonNum(b.Ticket) + `}`
appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk)
appelOAIInterne(t, "POST", "/internal/v1/oai/end", tk)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("étape de la façade non vue par la sauvegarde")
}
snap.save()
name := slotPersistName(persistTestKey, "c2")
if _, err := os.Stat(filepath.Join(home, "slots", name)); err != nil {
t.Fatalf("état non gardé : %v (%v)", err, m.got())
}
}
func jsonNum(n uint64) string {
b, _ := json.Marshal(n)
return string(b)
}
+28
View File
@@ -0,0 +1,28 @@
package loki
import (
"os"
"strings"
)
// facade_strata.go — Strata tenu par la façade Rust (LOKI_STRATA=rust, lot 25
// de la migration ; expérimental, le Go reste le défaut) : la machine et ce
// qui y tient, les réglages d'un modèle installé et l'installation (job des
// moteurs, tenu par la façade avec LOKI_BACKENDS=rust) —
// rust/loki-rs/src/strata. loki-rs ne passe la variable qu'avec les backends.
// Le lancement (`loki serve` d'un preset ENGINE=strata) reste ici.
const facadeStrataEnv = "LOKI_STRATA"
// facadeStrata : la façade tient-elle Strata ?
func facadeStrata() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeStrataEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeStrataRoutes : les routes que la façade sert alors.
var facadeStrataRoutes = map[string]bool{
"/api/strata": true,
"/api/strata/install": true,
"/api/strata/settings": true,
}
+67
View File
@@ -0,0 +1,67 @@
package loki
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// Strata part à la façade avec LOKI_STRATA=rust (lot 25) ; le reste du
// moteur, non.
func TestFacadeStrata(t *testing.T) {
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(facadeStrataEnv, "")
for p := range facadeStrataRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_STRATA, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeStrataEnv, " RUST")
if len(facadeStrataRoutes) != 3 {
t.Fatalf("routes : %v", facadeStrataRoutes)
}
for p := range facadeStrataRoutes {
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(facadeStrataRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil))
if w.Body.String() != "go" {
t.Fatalf("la compilation reste au Go : %q", w.Body.String())
}
t.Setenv(internalTokenEnv, "")
if facadeStrata() {
t.Fatal("tenu sans jeton interne")
}
}
+111
View File
@@ -0,0 +1,111 @@
package loki
import (
"encoding/json"
"net/http"
"os"
"strings"
)
// facade_system.go — interrupteurs système tenus par la façade Rust
// (LOKI_SYSTEM=rust, lot 17 de la migration ; expérimental, le Go reste le
// défaut) : accès internet (/api/internet), pilotage du navigateur
// (/api/computer), clé de complétion (/api/apikey) et écoute réseau du moteur
// (/api/network).
//
// Les réglages restent des clés de la base. Ce qui vit dans ce processus reste
// ici, derrière l'API interne :
//
// - le cache de joignabilité de Crawl4AI des outils web, vidé quand l'URL, la
// clé ou le moteur changent (/internal/v1/web/reach/reset) ;
// - le Chromium piloté par les outils browser_*, fermé quand l'interrupteur
// se coupe (/internal/v1/cdp/shutdown) ;
// - le redémarrage du moteur qui applique une nouvelle clé, quand la façade
// ne pilote pas le moteur (/internal/v1/service/action).
const facadeSystemEnv = "LOKI_SYSTEM"
// facadeSystem : la façade tient-elle les interrupteurs système ?
func facadeSystem() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSystemEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeSystemRoutes : les routes que la façade sert alors (system.rs).
var facadeSystemRoutes = map[string]bool{
"/api/internet": true,
"/api/computer": true,
"/api/apikey": true,
"/api/network": true,
}
// handleInternalReachReset : POST → 204 — le prochain tour reteste Crawl4AI.
func handleInternalReachReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return
}
reachMu.Lock()
reachURL = ""
reachMu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
// handleInternalCDPShutdown : POST → 204 — ferme le navigateur piloté.
func handleInternalCDPShutdown(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return
}
cdpShutdown()
w.WriteHeader(http.StatusNoContent)
}
// internalServiceAction : serviceAction, variable pour les tests.
var internalServiceAction = serviceAction
// internalServiceActionOS : serviceActionOS, variable pour les tests.
var internalServiceActionOS = func(a string) error { return serviceActionOSFn(a) }
// handleInternalServiceAction : POST {action, if_active} → 200 {ran, error}.
// if_active : seulement si le moteur tourne (une clé neuve ne s'applique qu'au
// redémarrage, inutile de démarrer un moteur arrêté pour elle).
func handleInternalServiceAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
return
}
var in struct {
Action string `json:"action"`
IfActive bool `json:"if_active"`
// OS : sans le refus de l'optimiseur (serviceActionOS) — pour la
// façade qui tient elle-même son verrou (LOKI_TUNE=rust).
OS bool `json:"os"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
switch in.Action {
case "start", "stop", "restart":
default:
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "action inconnue : " + in.Action})
return
}
if in.IfActive && !serviceIsActive() {
sendJSON(w, http.StatusOK, map[string]any{"ran": false, "error": ""})
return
}
act := internalServiceAction
if in.OS {
act = internalServiceActionOS
}
msg := ""
if err := act(in.Action); err != nil {
msg = err.Error()
}
if in.OS {
gpuMonitor.invalidate()
}
sendJSON(w, http.StatusOK, map[string]any{"ran": true, "error": msg})
}
+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)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package loki
import (
"fmt"
"net/http"
"os"
"strings"
)
// facade_tune.go — l'optimiseur tenu par la façade Rust (LOKI_TUNE=rust, lot
// 28 de la migration ; expérimental, le Go reste le défaut) : les routes
// /api/tune*, le job, les essais et l'application (rust/loki-rs/src/tune).
//
// Le verrou de l'optimiseur reste le FICHIER de LOKI_HOME, au même format
// (backend_tune_lock.go) : le Go le lit (tuneGuard), et sa reprise
// (tuneRecoverWatch) écarte celui d'une façade morte — essai orphelin arrêté,
// application défaite, moteur relancé. Le verrou de génération : le bail du
// moteur, motif « tune » (errTuneBusy pour les autres, le stop du chat
// annule). Les essais restent des « loki serve » du Go (LOKI_TUNE_TRIAL).
//
// Le Go prête ce qu'il est seul à voir (handleInternalTuneContext) et le
// démarrage et l'arrêt du moteur SANS le refus de l'optimiseur
// (handleInternalServiceAction, « os ») : c'est la façade qui tient le verrou.
const facadeTuneEnv = "LOKI_TUNE"
// facadeTune : la façade tient-elle l'optimiseur ?
func facadeTune() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeTuneEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeTuneRoutes : les routes que la façade sert alors.
var facadeTuneRoutes = map[string]bool{
"/api/tune": true,
"/api/tune/status": true,
"/api/tune/cancel": true,
"/api/tune/apply": true,
"/api/tune/last": true,
}
// handleInternalTuneContext : GET → {busy, main_active, perf}.
//
// - busy : ce qui interdit d'optimiser dans le processus web
// (tunePreflight) — un benchmark, une génération, des jobs du mode code ;
// "" sinon ;
// - main_active : le vrai moteur tourne (engineNeedsStop) ;
// - perf : la télémétrie, d'où l'optimiseur tire les poids d'un tour type.
func handleInternalTuneContext(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
return
}
busy := ""
switch {
case benchRunning():
busy = errBenchBusy.Error()
case conv.isGenerating():
busy = "génération en cours"
if err := conv.busyReason(); err != nil {
busy = err.Error()
}
case bgJobsRunning() > 0:
busy = fmt.Sprintf("%d job(s) d'arrière-plan du mode code tournent encore (bash_bg) : ils fausseraient les mesures — arrête-les d'abord",
bgJobsRunning())
}
sendJSON(w, http.StatusOK, map[string]any{"busy": busy, "main_active": engineNeedsStop(),
"perf": perfSnapshot()})
}
+168
View File
@@ -0,0 +1,168 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// L'optimiseur part à la façade avec LOKI_TUNE=rust (lot 28) ; le reste,
// non. La façade tient le verrou de génération par le bail du moteur, motif
// « tune » ; le Go lui prête ce qu'il est seul à voir et le démarrage du
// moteur sans le refus de l'optimiseur.
func TestFacadeTune(t *testing.T) {
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(facadeTuneEnv, "")
if facadeTune() {
t.Fatal("tenu sans LOKI_TUNE")
}
for p := range facadeTuneRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_TUNE, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeTuneEnv, " RUST")
if len(facadeTuneRoutes) != 5 {
t.Fatalf("routes : %v", facadeTuneRoutes)
}
for p := range facadeTuneRoutes {
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(facadeTuneRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/bench", local)(w, httptest.NewRequest("POST", "/api/bench", nil))
if w.Body.String() != "go" {
t.Fatalf("le banc d'essai reste au Go : %q", w.Body.String())
}
post := func(h http.HandlerFunc, path, body string) (int, map[string]any) {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
tuneCtx := func() map[string]any {
rec := httptest.NewRecorder()
handleInternalTuneContext(rec, httptest.NewRequest("GET", "/internal/v1/tune/context", nil))
if rec.Code != 200 {
t.Fatalf("contexte : %d", rec.Code)
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
// Au repos : rien n'interdit, la télémétrie suit.
perfRecord(perfRec{Kind: perfMain, Complete: true, Total: 100, New: 100, Gen: 10}, nil)
c := tuneCtx()
if c["busy"] != "" || c["main_active"] != false {
t.Fatalf("contexte au repos : %v", c)
}
if perf, _ := c["perf"].([]any); len(perf) == 0 {
t.Fatalf("télémétrie absente : %v", c["perf"])
}
rec := httptest.NewRecorder()
handleInternalTuneContext(rec, httptest.NewRequest("POST", "/internal/v1/tune/context", nil))
if rec.Code != 405 || rec.Header().Get("Allow") != "GET" {
t.Fatalf("POST : %d", rec.Code)
}
// Le bail « tune » : les autres reçoivent le refus de l'optimiseur ; ce
// n'est pas un benchmark.
code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"tune"}`)
if code != 200 {
t.Fatalf("bail : %d %v", code, out)
}
id, _ := out["lease"].(string)
if conv.busyReason() != errTuneBusy || benchRunning() || !conv.isGenerating() {
t.Fatalf("bail de l'optimiseur : refus=%v bench=%v", conv.busyReason(), benchRunning())
}
if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errTuneBusy.Error() {
t.Fatalf("second bail : %d %v", code, out)
}
if c := tuneCtx(); c["busy"] != errTuneBusy.Error() {
t.Fatalf("contexte pendant l'optimisation : %v", c)
}
// Changer de discussion ne rompt pas le bail de l'optimiseur.
conv.loadFrom("autre", nil)
if !conv.isGenerating() || conv.busyReason() != errTuneBusy {
t.Fatal("le bail de l'optimiseur a sauté à la bascule de discussion")
}
if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 {
t.Fatalf("libération : %d", code)
}
if conv.isGenerating() || conv.busyReason() == errTuneBusy {
t.Fatal("bail rendu : le verrou devait être libre")
}
// Un benchmark de la façade : le contexte le dit en premier.
code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{"why":"bench"}`)
if code != 200 {
t.Fatalf("bail du bench : %d %v", code, out)
}
if c := tuneCtx(); c["busy"] != errBenchBusy.Error() {
t.Fatalf("contexte pendant un bench : %v", c)
}
post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`)
// Démarrage et arrêt sans le refus de l'optimiseur : la façade tient le
// verrou.
var guarded, osActs []string
oldA, oldOS := internalServiceAction, internalServiceActionOS
t.Cleanup(func() { internalServiceAction, internalServiceActionOS = oldA, oldOS })
internalServiceAction = func(a string) error { guarded = append(guarded, a); return errTuneBusy }
internalServiceActionOS = func(a string) error { osActs = append(osActs, a); return nil }
for _, c := range []struct{ body, want string }{
{`{"action":"stop","os":true}`, `{"error":"","ran":true}`},
{`{"action":"start","os":true}`, `{"error":"","ran":true}`},
{`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`},
} {
w := httptest.NewRecorder()
handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body)))
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != c.want {
t.Fatalf("%s : %d %s", c.body, w.Code, got)
}
}
if strings.Join(osActs, ",") != "stop,start" || strings.Join(guarded, ",") != "start" {
t.Fatalf("actions : sans refus %v, avec %v", osActs, guarded)
}
t.Setenv(internalTokenEnv, "")
if facadeTune() {
t.Fatal("tenu sans jeton interne")
}
}
Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff. Show more