mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +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
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user