mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
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:
348 files changed
+259091
-701
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
+48
@@ -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.
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() != ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user