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:
Claude committed 2026-10-15 12:12:44 +00:00
1 parent daa2e7e4c1
commit 8fdef5455b
14 files changed
+655 -20

No files matched your search

+47
View File
@@ -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<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,
/// 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<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 {
/// L'état partagé des tours, autour de l'écrivain des discussions.
/// `img_dir` : `LOKI_HOME/chatimg`.
+73 -1
View File
@@ -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<Output = ()> + 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<Output = Result<(), String>> + 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<H: SessionHooks> Sessions<H> {
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`.
pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> {
let _op = self.op.lock().await;
+2 -1
View File
@@ -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;
+24 -3
View File
@@ -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<ChatService>, req: Request<Incoming>) ->
&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 })),
+29
View File
@@ -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<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.
async fn start_chat(
backend: SocketAddr,
+20
View File
@@ -54,6 +54,8 @@ pub struct FakeGo {
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
pub code: Mutex<bool>,
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 {
@@ -234,6 +236,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
});
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();
+98 -4
View File
@@ -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<Crochets>, texte: &str) {
fn dire<H: SessionHooks>(s: &Sessions<H>, texte: &str) {
let c = s.conv();
c.append_delta(
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| {
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);