diff --git a/README.md b/README.md index 39d4afc..9b4fec9 100644 --- a/README.md +++ b/README.md @@ -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_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` | `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 hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index af9f266..1bd95de 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -29,7 +29,9 @@ import ( // - les événements d'affichage nés ici (bascule du mode Code, critères) vont // dans SON journal ; // - 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 ( facadeChatEnv = "LOKI_CHAT" @@ -37,10 +39,85 @@ 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. +// 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 { 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)) } @@ -120,6 +197,61 @@ func facadePost(path string, body any) error { 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 // (tous les appareils le reçoivent). Rend false si la façade ne tient pas la // conversation : l'appelant journalise alors comme avant. diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index a27aa1b..70282f1 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -18,10 +18,27 @@ type fausseFacade struct { t *testing.T sse 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 { 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) @@ -35,7 +52,15 @@ func demarrerFacade(t *testing.T) *fausseFacade { return } 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 } io.WriteString(w, f.reply) @@ -139,3 +164,142 @@ func TestFacadeEvenementsEtExport(t *testing.T) { 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()) + } +} diff --git a/internal/loki/projects.go b/internal/loki/projects.go index c92a375..bf50fba 100644 --- a/internal/loki/projects.go +++ b/internal/loki/projects.go @@ -360,9 +360,23 @@ func deleteProject(slug string) error { if !found { return fmt.Errorf("projet introuvable") } - // Discussions du projet. - for _, m := range convIndexForProject(slug) { - _ = convDelete(m.ID) + // Discussions du projet. Quand la façade tient la conversation, c'est + // elle qui les supprime — après avoir quitté le projet s'il est actif : + // 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. for _, k := range trackerKeysForProject(slug) { diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 4354a19..5fad81b 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -99,6 +99,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalTurnDone(w, r) case "/internal/v1/turn/verifier": handleInternalTurnVerifier(w, r) + case "/internal/v1/project/activate": + handleInternalProjectActivate(w, r) + case "/internal/v1/chat/holder": + handleInternalChatHolder(w, r) case "/internal/v1/conv/active": handleInternalConvActive(w, r) case "/internal/v1/conv/dropped": diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go index dadb2e2..99bbc47 100644 --- a/internal/loki/web_internal_turn.go +++ b/internal/loki/web_internal_turn.go @@ -21,6 +21,8 @@ import ( // POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools} // 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/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} // (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier) // 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) { var in struct { Ms int64 `json:"ms"` diff --git a/internal/loki/web_projects.go b/internal/loki/web_projects.go index b02a224..11f9072 100644 --- a/internal/loki/web_projects.go +++ b/internal/loki/web_projects.go @@ -9,6 +9,7 @@ package loki import ( "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -89,7 +90,8 @@ func handleProjects(w http.ResponseWriter, r *http.Request) { err = setProjectDesc(slug, body.Desc) case "delete": // 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() { sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"}) 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"}) return } - err = projectSwitch(slug) + if facadeChat() { + if !projectExists(slug) { + err = fmt.Errorf("projet introuvable") + } else { + err = facadeProjectSwitch(slug) + } + } else { + err = projectSwitch(slug) + } default: err = fmt.Errorf("action inconnue : %q", body.Action) } 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 } sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()}) diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 89efe77..a81882f 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -617,6 +617,39 @@ impl ChatService { 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 { + 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, /// critères édités), sur le fil courant. 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, 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 { /// L'état partagé des tours, autour de l'écrivain des discussions. /// `img_dir` : `LOKI_HOME/chatimg`. diff --git a/rust/loki-rs/src/chat/sessions.rs b/rust/loki-rs/src/chat/sessions.rs index 43b8266..d45832b 100644 --- a/rust/loki-rs/src/chat/sessions.rs +++ b/rust/loki-rs/src/chat/sessions.rs @@ -16,7 +16,10 @@ //! 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 //! 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::sync::Arc; @@ -50,6 +53,11 @@ pub trait SessionHooks: Send + Sync + 'static { fn activated(&self, _id: &str) -> impl Future + Send { 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> + Send { + async { Ok(()) } + } } /// 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 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) { let body = serde_json::json!({ "id": id }); if let Err(e) = self @@ -361,6 +386,53 @@ impl Sessions { 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 { + let ids: Vec = 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`. pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> { let _op = self.op.lock().await; diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 9445c6a..7eb782f 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -27,7 +27,8 @@ //! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et //! 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 -//! 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::future::Future; diff --git a/rust/loki-rs/src/chatapi.rs b/rust/loki-rs/src/chatapi.rs index ba981e3..bb18d50 100644 --- a/rust/loki-rs/src/chatapi.rs +++ b/rust/loki-rs/src/chatapi.rs @@ -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 -/// lui (bascule du mode Code, critères) à journaliser ici, et le -/// rechargement de la discussion après le déverrouillage de la mémoire. +/// lui (bascule du mode Code, critères) à journaliser ici, le rechargement de +/// 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 { matches!( 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, req: Request) -> &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 { Ok(reloaded) => send_json(StatusCode::OK, &json!({ "reloaded": reloaded })), Err(e) => send_json(StatusCode::SERVICE_UNAVAILABLE, &json!({ "error": e })), diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index af10d8d..1d735ca 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -126,6 +126,21 @@ fn serve() -> ExitCode { let sup = child::Supervisor::spawn(go_bin, go_port, env); wait_for(backend, Duration::from_secs(30)).await; 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 { match start_chat(backend, &token, &engine_paths.home).await { 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")) } +/// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) +async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result, 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. async fn start_chat( backend: SocketAddr, diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 340e72b..0694f45 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -54,6 +54,8 @@ pub struct FakeGo { /// criteria (action=set) modifie les critères rangés (chat/crit:*). pub code: Mutex, pub tool_log: Mutex>, + /// Le Go garde la conversation (raison), sinon la façade la tient. + pub go_keeps_chat: Mutex>, } impl FakeGo { @@ -234,6 +236,24 @@ async fn handle(st: Arc, req: Request) -> Response }); 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" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index ebd2574..9532635 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -47,8 +47,8 @@ async fn outil_execute_par_le_go() { // ---- persistance des discussions par l'API interne ---- use loki_rs::chat::persist::{ - BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, Persister, StoreSink, ToolResJob, - decode_index, decode_state, + BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, DEFAULT_PROJECT_SLUG, Persister, + StoreSink, ToolResJob, decode_index, decode_state, }; use std::sync::Arc; @@ -281,7 +281,7 @@ async fn sessions() -> ( ) } -fn dire(s: &Sessions, texte: &str) { +fn dire(s: &Sessions, texte: &str) { let c = s.conv(); c.append_delta( c.epoch(), @@ -296,7 +296,7 @@ fn dire(s: &Sessions, texte: &str) { }); } -fn textes(s: &Sessions) -> Vec { +fn textes(s: &Sessions) -> Vec { s.conv().with_state(|st| { st.messages .iter() @@ -397,6 +397,68 @@ async fn creer_basculer_renommer() { 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] async fn supprimer_l_ouverte_puis_la_derniere() { 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())); + // 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. go.set("state", "web_key", "cle-web"); assert_eq!(call(facade, "GET", "/api/chat/state", "").await.0, 401);