mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Façade : changement de projet sans écraser la discussion, détenteur décidé par le Go
Avec LOKI_CHAT=rust, changer de projet passait par projectSwitch du Go, qui enregistrait SA copie de la discussion — chargée au démarrage, périmée — par-dessus celle de la façade, ouvrait une autre discussion que la façade ignorait, et ne voyait pas un tour de la façade en cours. Désormais : - /api/projects (switch) remet le changement à la façade (/internal/facade/v1/project/switch) : elle enregistre sa discussion avec le projet quitté, fait changer le projet actif au Go (/internal/v1/project/activate), ouvre la plus récente du nouveau projet (une neuve s'il n'en a pas) ; refusé pendant un tour (409) ; - supprimer un projet : la façade le quitte d'abord s'il est actif, puis supprime ses discussions (/internal/facade/v1/project/drop). Réglages que la façade ne sait pas honorer (PROJ_SNAPSHOT, KEEP_TURN_IMAGES, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs retry_patterns) : le Go garde la conversation, décidé une fois au démarrage (facadeChatBlocker) ; la façade le lit (/internal/v1/chat/holder) avant de servir quoi que ce soit, au lieu d'échouer en plein tour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
14 files changed
+655
-20
No files matched your search
@@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
|||||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
| `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_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_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||||
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours). Le Go garde le mode Code, les pièces jointes, les fichiers et l'export ; sa boucle de vérification du mode Code et les clés `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST` ne sont pas prises en charge dans ce mode | Go |
|
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
|
||||||
|
|
||||||
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
|
**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
|
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import (
|
|||||||
// - les événements d'affichage nés ici (bascule du mode Code, critères) vont
|
// - les événements d'affichage nés ici (bascule du mode Code, critères) vont
|
||||||
// dans SON journal ;
|
// dans SON journal ;
|
||||||
// - l'export lit la discussion enregistrée ;
|
// - l'export lit la discussion enregistrée ;
|
||||||
// - le déverrouillage de la mémoire lui fait recharger la discussion.
|
// - le déverrouillage de la mémoire lui fait recharger la discussion ;
|
||||||
|
// - changer de projet, ou en supprimer un, passe par elle : la discussion
|
||||||
|
// de ce process est périmée, l'enregistrer écraserait la sienne.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
facadeChatEnv = "LOKI_CHAT"
|
facadeChatEnv = "LOKI_CHAT"
|
||||||
@@ -37,10 +39,85 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// facadeChat : la façade tient-elle la conversation ? Il faut aussi son
|
// 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.
|
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et
|
||||||
|
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
|
||||||
func facadeChat() bool {
|
func facadeChat() bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
||||||
facadeAddr() != "" && internalToken() != ""
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
|
||||||
|
// {"holder":"go","reason":…}). La façade s'y range au démarrage.
|
||||||
|
func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||||
|
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if facadeChat() {
|
||||||
|
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
why := facadeChatDecision()
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
|
||||||
|
why = facadeChatEnv + " absent"
|
||||||
|
}
|
||||||
|
sendJSON(w, http.StatusOK, map[string]any{"holder": "go", "reason": why})
|
||||||
}
|
}
|
||||||
|
|
||||||
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
|
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
|
||||||
@@ -120,6 +197,61 @@ func facadePost(path string, body any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
|
||||||
|
type facadeError struct {
|
||||||
|
status int
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *facadeError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// facadeCall appelle l'API interne de la façade et rend son refus éventuel
|
||||||
|
// tel quel (statut et message), pour les actions dont l'utilisateur attend
|
||||||
|
// la réponse.
|
||||||
|
func facadeCall(path string, body 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())
|
||||||
|
// Délai large : enregistrer une discussion et en ouvrir une autre passe
|
||||||
|
// par le disque (et par le chiffrement).
|
||||||
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("conversation indisponible : %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
if out.Error == "" {
|
||||||
|
out.Error = "façade : " + resp.Status
|
||||||
|
}
|
||||||
|
return &facadeError{status: resp.StatusCode, msg: out.Error}
|
||||||
|
}
|
||||||
|
|
||||||
|
// facadeProjectSwitch : la façade enregistre la discussion du projet quitté
|
||||||
|
// (avec son projet), fait changer le projet actif ici
|
||||||
|
// (/internal/v1/project/activate), puis ouvre une discussion du nouveau.
|
||||||
|
func facadeProjectSwitch(slug string) error {
|
||||||
|
return facadeCall("/internal/facade/v1/project/switch", map[string]any{"slug": slug})
|
||||||
|
}
|
||||||
|
|
||||||
|
// facadeDropProject : la façade supprime les discussions d'un projet (index,
|
||||||
|
// contenu, et ce qui les entoure ici par /internal/v1/conv/dropped).
|
||||||
|
func facadeDropProject(slug string) error {
|
||||||
|
return facadeCall("/internal/facade/v1/project/drop", map[string]any{"slug": slug})
|
||||||
|
}
|
||||||
|
|
||||||
// facadeDelta : un événement d'affichage né ici, journalisé par la façade
|
// facadeDelta : un événement d'affichage né ici, journalisé par la façade
|
||||||
// (tous les appareils le reçoivent). Rend false si la façade ne tient pas la
|
// (tous les appareils le reçoivent). Rend false si la façade ne tient pas la
|
||||||
// conversation : l'appelant journalise alors comme avant.
|
// conversation : l'appelant journalise alors comme avant.
|
||||||
|
|||||||
@@ -18,10 +18,27 @@ type fausseFacade struct {
|
|||||||
t *testing.T
|
t *testing.T
|
||||||
sse string
|
sse string
|
||||||
reply string
|
reply string
|
||||||
|
// Réponse de l'API interne (0 : 204 sans corps).
|
||||||
|
internalStatus int
|
||||||
|
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 {
|
func demarrerFacade(t *testing.T) *fausseFacade {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
oublierDecisionFacade(t)
|
||||||
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
||||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
b, _ := io.ReadAll(r.Body)
|
b, _ := io.ReadAll(r.Body)
|
||||||
@@ -35,7 +52,15 @@ func demarrerFacade(t *testing.T) *fausseFacade {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(r.URL.Path, "/internal/facade/") {
|
if strings.HasPrefix(r.URL.Path, "/internal/facade/") {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
f.mu.Lock()
|
||||||
|
st, body := f.internalStatus, f.internalBody
|
||||||
|
f.mu.Unlock()
|
||||||
|
if st == 0 {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(st)
|
||||||
|
io.WriteString(w, body)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
io.WriteString(w, f.reply)
|
io.WriteString(w, f.reply)
|
||||||
@@ -139,3 +164,142 @@ func TestFacadeEvenementsEtExport(t *testing.T) {
|
|||||||
t.Fatalf("export : %v %s", err, body)
|
t.Fatalf("export : %v %s", err, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Changer de projet quand la façade tient la conversation : c'est elle qui
|
||||||
|
// enregistre sa discussion et en ouvre une autre (la copie d'ici est périmée,
|
||||||
|
// l'enregistrer écraserait la sienne). Son refus (tour en cours) remonte tel
|
||||||
|
// quel ; un projet inconnu est refusé ici sans la déranger.
|
||||||
|
func TestFacadeChangeDeProjet(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
ensureDefaultProject()
|
||||||
|
p, err := createProject("Deux")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := demarrerFacade(t)
|
||||||
|
post := func(body string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(body)))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
before := activeProjectSlug()
|
||||||
|
if w := post(`{"action":"switch","slug":"` + p.Slug + `"}`); w.Code != 200 {
|
||||||
|
t.Fatalf("bascule : %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := f.recu(); len(got) != 1 || got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+p.Slug+`"}` {
|
||||||
|
t.Fatalf("reçu par la façade : %v", got)
|
||||||
|
}
|
||||||
|
// Le projet actif ne change que par la façade (/internal/v1/project/activate).
|
||||||
|
if activeProjectSlug() != before {
|
||||||
|
t.Fatalf("projet actif changé sans la façade : %s", activeProjectSlug())
|
||||||
|
}
|
||||||
|
if w := post(`{"action":"switch","slug":"inconnu"}`); w.Code != 400 || len(f.recu()) != 1 {
|
||||||
|
t.Fatalf("projet inconnu : %d, façade %v", w.Code, f.recu())
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.internalStatus, f.internalBody = 409, `{"ok":false,"error":"une génération est en cours"}`
|
||||||
|
f.mu.Unlock()
|
||||||
|
w := post(`{"action":"switch","slug":"` + p.Slug + `"}`)
|
||||||
|
if w.Code != 409 || !strings.Contains(w.Body.String(), "une génération est en cours") {
|
||||||
|
t.Fatalf("refus de la façade : %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer le projet actif : la façade le quitte d'abord (discussion
|
||||||
|
// enregistrée, une du projet restant ouverte), puis supprime ses discussions.
|
||||||
|
func TestFacadeSuppressionDeProjet(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
ensureDefaultProject()
|
||||||
|
p, err := createProject("Deux")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := setActiveProject(p.Slug); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := demarrerFacade(t)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`)))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("suppression : %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := f.recu()
|
||||||
|
if len(got) != 2 ||
|
||||||
|
got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+defaultProjectSlug+`"}` ||
|
||||||
|
got[1] != `POST /internal/facade/v1/project/drop jeton {"slug":"`+p.Slug+`"}` {
|
||||||
|
t.Fatalf("reçu par la façade : %v", got)
|
||||||
|
}
|
||||||
|
if projectExists(p.Slug) {
|
||||||
|
t.Fatal("projet toujours là")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /internal/v1/project/activate : le projet actif change, s'il existe.
|
||||||
|
func TestFacadeActivationDeProjet(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
ensureDefaultProject()
|
||||||
|
p, err := createProject("Trois")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(internalTokenEnv, jetonTest)
|
||||||
|
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"`+p.Slug+`"}`); w.Code != 204 {
|
||||||
|
t.Fatalf("activation : %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if activeProjectSlug() != p.Slug {
|
||||||
|
t.Fatalf("actif : %s", activeProjectSlug())
|
||||||
|
}
|
||||||
|
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"nulle-part"}`); w.Code != 400 {
|
||||||
|
t.Fatalf("inconnu : %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
testHome(t)
|
||||||
|
f := demarrerFacade(t)
|
||||||
|
t.Setenv(internalTokenEnv, jetonTest)
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,9 +360,23 @@ func deleteProject(slug string) error {
|
|||||||
if !found {
|
if !found {
|
||||||
return fmt.Errorf("projet introuvable")
|
return fmt.Errorf("projet introuvable")
|
||||||
}
|
}
|
||||||
// Discussions du projet.
|
// Discussions du projet. Quand la façade tient la conversation, c'est
|
||||||
for _, m := range convIndexForProject(slug) {
|
// elle qui les supprime — après avoir quitté le projet s'il est actif :
|
||||||
_ = convDelete(m.ID)
|
// elle enregistre sa discussion et en ouvre une du projet restant, et ne
|
||||||
|
// supprime donc jamais celle qu'elle a ouverte.
|
||||||
|
if facadeChat() {
|
||||||
|
if activeProjectSlug() == slug {
|
||||||
|
if err := facadeProjectSwitch(kept[0].Slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := facadeDropProject(slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, m := range convIndexForProject(slug) {
|
||||||
|
_ = convDelete(m.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Trackers du projet.
|
// Trackers du projet.
|
||||||
for _, k := range trackerKeysForProject(slug) {
|
for _, k := range trackerKeysForProject(slug) {
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ func withInternalAPI(next http.Handler) http.Handler {
|
|||||||
handleInternalTurnDone(w, r)
|
handleInternalTurnDone(w, r)
|
||||||
case "/internal/v1/turn/verifier":
|
case "/internal/v1/turn/verifier":
|
||||||
handleInternalTurnVerifier(w, r)
|
handleInternalTurnVerifier(w, r)
|
||||||
|
case "/internal/v1/project/activate":
|
||||||
|
handleInternalProjectActivate(w, r)
|
||||||
|
case "/internal/v1/chat/holder":
|
||||||
|
handleInternalChatHolder(w, r)
|
||||||
case "/internal/v1/conv/active":
|
case "/internal/v1/conv/active":
|
||||||
handleInternalConvActive(w, r)
|
handleInternalConvActive(w, r)
|
||||||
case "/internal/v1/conv/dropped":
|
case "/internal/v1/conv/dropped":
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools}
|
// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools}
|
||||||
// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go)
|
// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go)
|
||||||
// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés)
|
// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés)
|
||||||
|
// POST /internal/v1/project/activate {"slug":…} → 204 (projet actif changé
|
||||||
|
// par la façade, qui a enregistré la discussion du projet quitté)
|
||||||
// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps}
|
// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps}
|
||||||
// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier)
|
// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier)
|
||||||
// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils)
|
// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils)
|
||||||
@@ -131,6 +133,26 @@ func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInternalProjectActivate : setActiveProject pour la façade (projet
|
||||||
|
// rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils).
|
||||||
|
func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if !readInternalJSON(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !projectExists(in.Slug) {
|
||||||
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "projet introuvable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := setActiveProject(in.Slug); err != nil {
|
||||||
|
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) {
|
func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
Ms int64 `json:"ms"`
|
Ms int64 `json:"ms"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package loki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -89,7 +90,8 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = setProjectDesc(slug, body.Desc)
|
err = setProjectDesc(slug, body.Desc)
|
||||||
case "delete":
|
case "delete":
|
||||||
// Une génération en cours travaille dans la mémoire du projet actif :
|
// Une génération en cours travaille dans la mémoire du projet actif :
|
||||||
// supprimer sous ses pieds la laisserait écrire dans le vide.
|
// supprimer sous ses pieds la laisserait écrire dans le vide. La
|
||||||
|
// façade, quand elle tient la conversation, refuse elle-même (409).
|
||||||
if conv.isGenerating() {
|
if conv.isGenerating() {
|
||||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
||||||
return
|
return
|
||||||
@@ -100,12 +102,25 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = projectSwitch(slug)
|
if facadeChat() {
|
||||||
|
if !projectExists(slug) {
|
||||||
|
err = fmt.Errorf("projet introuvable")
|
||||||
|
} else {
|
||||||
|
err = facadeProjectSwitch(slug)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = projectSwitch(slug)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("action inconnue : %q", body.Action)
|
err = fmt.Errorf("action inconnue : %q", body.Action)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
code := 400
|
||||||
|
var fe *facadeError
|
||||||
|
if errors.As(err, &fe) && fe.status == http.StatusConflict {
|
||||||
|
code = http.StatusConflict
|
||||||
|
}
|
||||||
|
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
|
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
|
||||||
|
|||||||
@@ -617,6 +617,39 @@ impl ChatService {
|
|||||||
Some(String::from_utf8_lossy(&b).into_owned())
|
Some(String::from_utf8_lossy(&b).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Changement de projet (`/api/projects`, action switch), remis par le
|
||||||
|
/// Go : refusé pendant un tour.
|
||||||
|
pub async fn project_switch(&self, slug: &str) -> Result<(), Refused> {
|
||||||
|
if self.sessions.conv().is_generating() {
|
||||||
|
return Err(Refused {
|
||||||
|
status: 409,
|
||||||
|
error: "une génération est en cours".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.sessions
|
||||||
|
.project_switch(slug)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Refused {
|
||||||
|
status: 400,
|
||||||
|
error: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suppression d'un projet (`deleteProject`) : ses discussions d'abord,
|
||||||
|
/// refusé pendant un tour.
|
||||||
|
pub async fn drop_project(&self, slug: &str) -> Result<usize, Refused> {
|
||||||
|
if self.sessions.conv().is_generating() {
|
||||||
|
return Err(Refused {
|
||||||
|
status: 409,
|
||||||
|
error: "une génération est en cours".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.sessions.drop_project(slug).await.map_err(|e| Refused {
|
||||||
|
status: 400,
|
||||||
|
error: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Un événement d'affichage né hors d'un tour (bascule du mode Code,
|
/// Un événement d'affichage né hors d'un tour (bascule du mode Code,
|
||||||
/// critères édités), sur le fil courant.
|
/// critères édités), sur le fil courant.
|
||||||
pub fn push_delta(&self, d: Delta) {
|
pub fn push_delta(&self, d: Delta) {
|
||||||
@@ -998,6 +1031,20 @@ impl ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Qui tient la conversation, d'après le Go (`/internal/v1/chat/holder`) :
|
||||||
|
/// `None` : la façade ; `Some(raison)` : le Go la garde (réglage que la façade
|
||||||
|
/// ne sait pas honorer). Décidé une fois par process, côté Go.
|
||||||
|
pub async fn go_keeps_chat(store: &crate::store::Store) -> Result<Option<String>, String> {
|
||||||
|
let v = store
|
||||||
|
.internal_json(hyper::Method::GET, "/internal/v1/chat/holder", None)
|
||||||
|
.await?;
|
||||||
|
match v["holder"].as_str() {
|
||||||
|
Some("rust") => Ok(None),
|
||||||
|
Some("go") => Ok(Some(v["reason"].as_str().unwrap_or("").to_string())),
|
||||||
|
_ => Err(format!("détenteur de la conversation illisible : {v}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ChatShared {
|
impl ChatShared {
|
||||||
/// L'état partagé des tours, autour de l'écrivain des discussions.
|
/// L'état partagé des tours, autour de l'écrivain des discussions.
|
||||||
/// `img_dir` : `LOKI_HOME/chatimg`.
|
/// `img_dir` : `LOKI_HOME/chatimg`.
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
//! fichiers, travaux, critères et mode d'une discussion supprimée, ses
|
//! fichiers, travaux, critères et mode d'une discussion supprimée, ses
|
||||||
//! résultats d'outils, le préchauffage après une bascule, et la discussion
|
//! résultats d'outils, le préchauffage après une bascule, et la discussion
|
||||||
//! active vue par les outils du Go ([`GoSessionHooks`]). Le changement de
|
//! active vue par les outils du Go ([`GoSessionHooks`]). Le changement de
|
||||||
//! projet (`projectSwitch`) reste au Go pour l'instant.
|
//! projet ([`Sessions::project_switch`]) se fait ici : la discussion du
|
||||||
|
//! projet quitté est enregistrée avant que le Go ne change le projet actif
|
||||||
|
//! ([`SessionHooks::project`]) ; les discussions d'un projet supprimé
|
||||||
|
//! partent par ici aussi ([`Sessions::drop_project`]).
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -50,6 +53,11 @@ pub trait SessionHooks: Send + Sync + 'static {
|
|||||||
fn activated(&self, _id: &str) -> impl Future<Output = ()> + Send {
|
fn activated(&self, _id: &str) -> impl Future<Output = ()> + Send {
|
||||||
async {}
|
async {}
|
||||||
}
|
}
|
||||||
|
/// `setActiveProject` : le projet actif change (le Go le range et tient
|
||||||
|
/// le cache de ceux qui le lisent : mémoire, trackers, outils).
|
||||||
|
fn project(&self, _slug: &str) -> impl Future<Output = Result<(), String>> + Send {
|
||||||
|
async { Ok(()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Les effets réels, par l'API interne du Go.
|
/// Les effets réels, par l'API interne du Go.
|
||||||
@@ -73,6 +81,23 @@ impl SessionHooks for GoSessionHooks {
|
|||||||
|
|
||||||
async fn switched(&self, _id: &str) {}
|
async fn switched(&self, _id: &str) {}
|
||||||
|
|
||||||
|
async fn project(&self, slug: &str) -> Result<(), String> {
|
||||||
|
let (status, v) = self
|
||||||
|
.0
|
||||||
|
.internal_post(
|
||||||
|
"/internal/v1/project/activate",
|
||||||
|
&serde_json::json!({ "slug": slug }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if status.is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(v["error"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("projet non activé : {status}")))
|
||||||
|
}
|
||||||
|
|
||||||
async fn activated(&self, id: &str) {
|
async fn activated(&self, id: &str) {
|
||||||
let body = serde_json::json!({ "id": id });
|
let body = serde_json::json!({ "id": id });
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
@@ -361,6 +386,53 @@ impl<H: SessionHooks> Sessions<H> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `projectSwitch` : la discussion du projet quitté est enregistrée (avec
|
||||||
|
/// son projet), le projet actif change, puis la plus récente du nouveau
|
||||||
|
/// projet s'ouvre — une neuve s'il n'en a pas. L'appelant vérifie
|
||||||
|
/// qu'aucun tour ne tourne.
|
||||||
|
pub async fn project_switch(&self, slug: &str) -> Result<(), SessionError> {
|
||||||
|
let slug = slug.trim();
|
||||||
|
if slug.is_empty() {
|
||||||
|
return Err(SessionError::Missing);
|
||||||
|
}
|
||||||
|
let _op = self.op.lock().await;
|
||||||
|
if slug == self.active_project().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.persist().await; // fige la discussion du projet qu'on quitte
|
||||||
|
self.hooks
|
||||||
|
.project(slug)
|
||||||
|
.await
|
||||||
|
.map_err(SessionError::Store)?;
|
||||||
|
let idx = self.read_index().await;
|
||||||
|
match index_for_project(&idx, slug).first() {
|
||||||
|
None => self.create_locked().await.map(|_| ()),
|
||||||
|
Some(m) => {
|
||||||
|
let id = m.id.clone();
|
||||||
|
self.activate(&id).await?;
|
||||||
|
self.hooks.switched(&id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Les discussions d'un projet supprimé (`deleteProject`), une à une
|
||||||
|
/// comme [`Self::delete`] : supprimer l'ouverte bascule sur la plus
|
||||||
|
/// récente restante. Rend leur nombre.
|
||||||
|
pub async fn drop_project(&self, slug: &str) -> Result<usize, SessionError> {
|
||||||
|
let ids: Vec<String> = index_for_project(&self.read_index().await, slug.trim())
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| m.id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in &ids {
|
||||||
|
match self.delete(id).await {
|
||||||
|
Ok(()) | Err(SessionError::NotFound) => {}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ids.len())
|
||||||
|
}
|
||||||
|
|
||||||
/// `convRename`.
|
/// `convRename`.
|
||||||
pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> {
|
pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> {
|
||||||
let _op = self.op.lock().await;
|
let _op = self.op.lock().await;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et
|
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et
|
||||||
//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit,
|
//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit,
|
||||||
//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une
|
//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une
|
||||||
//! de ces clés, la conversation doit rester au Go.
|
//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au
|
||||||
|
//! démarrage).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|||||||
@@ -55,12 +55,16 @@ pub fn handles(path: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// API interne de la façade, pour le Go : un événement d'affichage né chez
|
/// API interne de la façade, pour le Go : un événement d'affichage né chez
|
||||||
/// lui (bascule du mode Code, critères) à journaliser ici, et le
|
/// lui (bascule du mode Code, critères) à journaliser ici, le rechargement de
|
||||||
/// rechargement de la discussion après le déverrouillage de la mémoire.
|
/// la discussion après le déverrouillage de la mémoire, le changement de
|
||||||
|
/// projet et les discussions d'un projet supprimé.
|
||||||
pub fn is_internal(path: &str) -> bool {
|
pub fn is_internal(path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/internal/facade/v1/delta" | "/internal/facade/v1/reload"
|
"/internal/facade/v1/delta"
|
||||||
|
| "/internal/facade/v1/reload"
|
||||||
|
| "/internal/facade/v1/project/switch"
|
||||||
|
| "/internal/facade/v1/project/drop"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +90,23 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
|
|||||||
&json!({ "error": "événement illisible" }),
|
&json!({ "error": "événement illisible" }),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"/internal/facade/v1/project/switch" | "/internal/facade/v1/project/drop" => {
|
||||||
|
let v = read_json(req.into_body()).await.unwrap_or_default();
|
||||||
|
let slug = str_of(&v, "slug");
|
||||||
|
let res = if path.ends_with("switch") {
|
||||||
|
chat.project_switch(slug)
|
||||||
|
.await
|
||||||
|
.map(|()| json!({ "ok": true }))
|
||||||
|
} else {
|
||||||
|
chat.drop_project(slug)
|
||||||
|
.await
|
||||||
|
.map(|n| json!({ "ok": true, "dropped": n }))
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(v) => send_json(StatusCode::OK, &v),
|
||||||
|
Err(r) => refused(r.status, &r.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => match chat.sessions.reload_if_empty().await {
|
_ => match chat.sessions.reload_if_empty().await {
|
||||||
Ok(reloaded) => send_json(StatusCode::OK, &json!({ "reloaded": reloaded })),
|
Ok(reloaded) => send_json(StatusCode::OK, &json!({ "reloaded": reloaded })),
|
||||||
Err(e) => send_json(StatusCode::SERVICE_UNAVAILABLE, &json!({ "error": e })),
|
Err(e) => send_json(StatusCode::SERVICE_UNAVAILABLE, &json!({ "error": e })),
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ fn serve() -> ExitCode {
|
|||||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||||
wait_for(backend, Duration::from_secs(30)).await;
|
wait_for(backend, Duration::from_secs(30)).await;
|
||||||
let engine_paths = engine::Paths::from_env();
|
let engine_paths = engine::Paths::from_env();
|
||||||
|
// Le Go peut garder la conversation malgré LOKI_CHAT=rust (réglage que
|
||||||
|
// la façade ne sait pas encore honorer) : sa décision fait foi.
|
||||||
|
let chat_rust = chat_rust
|
||||||
|
&& match go_keeps_chat(backend, &token).await {
|
||||||
|
Ok(None) => true,
|
||||||
|
Ok(Some(why)) => {
|
||||||
|
eprintln!("[loki-rs] conversation tenue par le Go ({why})");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[loki-rs] conversation : {e}");
|
||||||
|
sup.shutdown(Duration::from_secs(10)).await;
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
let chat = if chat_rust {
|
let chat = if chat_rust {
|
||||||
match start_chat(backend, &token, &engine_paths.home).await {
|
match start_chat(backend, &token, &engine_paths.home).await {
|
||||||
Ok(c) => Some(c),
|
Ok(c) => Some(c),
|
||||||
@@ -174,6 +189,20 @@ fn chat_held_by_facade() -> bool {
|
|||||||
std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
|
std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer)
|
||||||
|
async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result<Option<String>, String> {
|
||||||
|
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||||
|
let mut last = String::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
match loki_rs::chat::generate::go_keeps_chat(&store).await {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(e) => last = e,
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err(last)
|
||||||
|
}
|
||||||
|
|
||||||
/// Charge la discussion active et prépare le service des tours.
|
/// Charge la discussion active et prépare le service des tours.
|
||||||
async fn start_chat(
|
async fn start_chat(
|
||||||
backend: SocketAddr,
|
backend: SocketAddr,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub struct FakeGo {
|
|||||||
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
|
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
|
||||||
pub code: Mutex<bool>,
|
pub code: Mutex<bool>,
|
||||||
pub tool_log: Mutex<Vec<String>>,
|
pub tool_log: Mutex<Vec<String>>,
|
||||||
|
/// Le Go garde la conversation (raison), sinon la façade la tient.
|
||||||
|
pub go_keeps_chat: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeGo {
|
impl FakeGo {
|
||||||
@@ -234,6 +236,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
|||||||
});
|
});
|
||||||
resp(200, out.to_string())
|
resp(200, out.to_string())
|
||||||
}
|
}
|
||||||
|
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
|
||||||
|
None => resp(200, r#"{"holder":"rust"}"#),
|
||||||
|
Some(why) => resp(
|
||||||
|
200,
|
||||||
|
serde_json::json!({"holder": "go", "reason": why}).to_string(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"/internal/v1/project/activate" => {
|
||||||
|
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||||
|
let slug = v["slug"].as_str().unwrap_or_default().to_string();
|
||||||
|
if slug.is_empty() || slug == "absent" {
|
||||||
|
return resp(400, r#"{"error":"projet introuvable"}"#);
|
||||||
|
}
|
||||||
|
st.conv_log.lock().unwrap().push(format!("project {slug}"));
|
||||||
|
st.set("state", "active_project", &slug);
|
||||||
|
resp(204, "")
|
||||||
|
}
|
||||||
"/internal/v1/turn/verifier" => {
|
"/internal/v1/turn/verifier" => {
|
||||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ async fn outil_execute_par_le_go() {
|
|||||||
// ---- persistance des discussions par l'API interne ----
|
// ---- persistance des discussions par l'API interne ----
|
||||||
|
|
||||||
use loki_rs::chat::persist::{
|
use loki_rs::chat::persist::{
|
||||||
BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, Persister, StoreSink, ToolResJob,
|
BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, DEFAULT_PROJECT_SLUG, Persister,
|
||||||
decode_index, decode_state,
|
StoreSink, ToolResJob, decode_index, decode_state,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ async fn sessions() -> (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dire(s: &Sessions<Crochets>, texte: &str) {
|
fn dire<H: SessionHooks>(s: &Sessions<H>, texte: &str) {
|
||||||
let c = s.conv();
|
let c = s.conv();
|
||||||
c.append_delta(
|
c.append_delta(
|
||||||
c.epoch(),
|
c.epoch(),
|
||||||
@@ -296,7 +296,7 @@ fn dire(s: &Sessions<Crochets>, texte: &str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn textes(s: &Sessions<Crochets>) -> Vec<String> {
|
fn textes<H: SessionHooks>(s: &Sessions<H>) -> Vec<String> {
|
||||||
s.conv().with_state(|st| {
|
s.conv().with_state(|st| {
|
||||||
st.messages
|
st.messages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -397,6 +397,68 @@ async fn creer_basculer_renommer() {
|
|||||||
assert_eq!(s.rename(&a, " ").await, Err(SessionError::Missing));
|
assert_eq!(s.rename(&a, " ").await, Err(SessionError::Missing));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Qui tient la conversation : le Go en décide (réglage qu'il garde).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detenteur_de_la_conversation() {
|
||||||
|
let (st, go, _stop, _t) = store().await;
|
||||||
|
assert_eq!(loki_rs::chat::generate::go_keeps_chat(&st).await, Ok(None));
|
||||||
|
*go.go_keeps_chat.lock().unwrap() = Some("PROJ_SNAPSHOT".into());
|
||||||
|
assert_eq!(
|
||||||
|
loki_rs::chat::generate::go_keeps_chat(&st).await,
|
||||||
|
Ok(Some("PROJ_SNAPSHOT".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changer de projet : la discussion quittée est rangée avec SON projet, la
|
||||||
|
/// plus récente du nouveau s'ouvre (une neuve s'il n'en a pas) ; supprimer un
|
||||||
|
/// projet emporte ses discussions.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changer_de_projet_et_en_supprimer_un() {
|
||||||
|
let (st, go, _stop, _t) = store().await;
|
||||||
|
let s = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
|
||||||
|
s.load().await.unwrap();
|
||||||
|
let a = s.conv().id();
|
||||||
|
dire(&s, "dans le projet par défaut");
|
||||||
|
s.project_switch("deux").await.unwrap();
|
||||||
|
assert_eq!(go.get("state", "active_project").unwrap(), "deux");
|
||||||
|
let b = s.conv().id();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert!(textes(&s).is_empty());
|
||||||
|
let idx = s.read_index().await;
|
||||||
|
let proj = |id: &str| idx.iter().find(|m| m.id == id).unwrap().project.clone();
|
||||||
|
assert_eq!(proj(&a), DEFAULT_PROJECT_SLUG);
|
||||||
|
assert_eq!(proj(&b), "deux");
|
||||||
|
let body = opened(&go, BK_CHAT, &format!("conv:{a}")).unwrap();
|
||||||
|
assert_eq!(decode_state(&body).messages.len(), 1);
|
||||||
|
dire(&s, "dans deux");
|
||||||
|
s.project_switch(DEFAULT_PROJECT_SLUG).await.unwrap();
|
||||||
|
assert_eq!(s.conv().id(), a);
|
||||||
|
assert_eq!(textes(&s), ["dans le projet par défaut"]);
|
||||||
|
// Même projet : rien ; projet refusé par le Go : rien ne change.
|
||||||
|
s.project_switch(DEFAULT_PROJECT_SLUG).await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
s.project_switch("absent").await,
|
||||||
|
Err(SessionError::Store(_))
|
||||||
|
));
|
||||||
|
assert_eq!(s.project_switch(" ").await, Err(SessionError::Missing));
|
||||||
|
assert_eq!(s.conv().id(), a);
|
||||||
|
assert_eq!(
|
||||||
|
go.get("state", "active_project").unwrap(),
|
||||||
|
DEFAULT_PROJECT_SLUG
|
||||||
|
);
|
||||||
|
// Les discussions de « deux » partent : contenu, index, nettoyage du Go.
|
||||||
|
assert_eq!(s.drop_project("deux").await.unwrap(), 1);
|
||||||
|
assert!(s.read_index().await.iter().all(|m| m.id != b));
|
||||||
|
assert!(go.get(BK_CHAT, &format!("conv:{b}")).is_none());
|
||||||
|
assert!(
|
||||||
|
go.conv_log
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&format!("dropped {b}"))
|
||||||
|
);
|
||||||
|
assert_eq!(s.conv().id(), a);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn supprimer_l_ouverte_puis_la_derniere() {
|
async fn supprimer_l_ouverte_puis_la_derniere() {
|
||||||
let (s, h, go, _stop, _t) = sessions().await;
|
let (s, h, go, _stop, _t) = sessions().await;
|
||||||
@@ -1311,6 +1373,38 @@ async fn routes_du_chat_par_la_facade() {
|
|||||||
);
|
);
|
||||||
assert!(!chat.sessions.conv().with_state(|st| st.log.is_empty()));
|
assert!(!chat.sessions.conv().with_state(|st| st.log.is_empty()));
|
||||||
|
|
||||||
|
// Changement de projet remis par le Go : la discussion part avec son
|
||||||
|
// projet, une neuve s'ouvre dans le nouveau.
|
||||||
|
assert_eq!(
|
||||||
|
call_internal(
|
||||||
|
facade,
|
||||||
|
"/internal/facade/v1/project/switch",
|
||||||
|
r#"{"slug":"deux"}"#
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert_ne!(chat.sessions.conv().id(), a);
|
||||||
|
assert_eq!(
|
||||||
|
call_internal(
|
||||||
|
facade,
|
||||||
|
"/internal/facade/v1/project/switch",
|
||||||
|
r#"{"slug":""}"#
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call_internal(
|
||||||
|
facade,
|
||||||
|
"/internal/facade/v1/project/switch",
|
||||||
|
r#"{"slug":"generale"}"#
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert_eq!(chat.sessions.conv().id(), a);
|
||||||
|
|
||||||
// Trafic remis par le Go (tunnel) : déjà authentifié par lui.
|
// Trafic remis par le Go (tunnel) : déjà authentifié par lui.
|
||||||
go.set("state", "web_key", "cle-web");
|
go.set("state", "web_key", "cle-web");
|
||||||
assert_eq!(call(facade, "GET", "/api/chat/state", "").await.0, 401);
|
assert_eq!(call(facade, "GET", "/api/chat/state", "").await.0, 401);
|
||||||
|
|||||||
Reference in new issue
Block a user