mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 21:48:47 +02:00
Lot 18 : hub des projets tenu par la façade (LOKI_PROJECTS=rust)
La façade sert /api/projects quand LOKI_PROJECTS=rust (expérimental ; le Go reste le défaut) : liste avec discussions, pages et trackers comptés, création (slug translittéré et unique, index amorcé), renommage, description, suppression (discussions, trackers et mémoire du projet, actif réassigné, dernier projet indestructible) et bascule, refusées pendant une génération. Le projet actif passe toujours par le Go, qui en garde une copie en RAM. Les discussions sont à qui tient la conversation : la façade bascule et supprime elle-même quand elle la tient, sinon le Go garde /internal/v1/project/switch et /internal/v1/project/drop-convs (facade_projects.go). Parité : parity_projects_test.go tire du Go 40 appels sur une base amorcée (pages, discussions, trackers piégés), avec le registre, l'actif, memory/, les trackers et l'index après chacun, rejoués par tests/projects_api.rs contre un faux Go qui bascule et supprime comme le vrai ; bascule et suppression par la façade de bout en bout ; TestFacadeProjets côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+5707
-4
No files matched your search
@@ -46,6 +46,8 @@ pub struct Ctx {
|
||||
pub engine_ctl: Option<std::sync::Arc<crate::enginectl::EngineCtl>>,
|
||||
/// Interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`).
|
||||
pub system: Option<std::sync::Arc<crate::system::System>>,
|
||||
/// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
|
||||
pub projects: bool,
|
||||
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
||||
pub internal_token: String,
|
||||
}
|
||||
@@ -64,6 +66,7 @@ impl Ctx {
|
||||
|| self.presets
|
||||
|| self.engine_ctl.is_some()
|
||||
|| self.system.is_some()
|
||||
|| self.projects
|
||||
}
|
||||
|
||||
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
||||
|
||||
@@ -359,7 +359,7 @@ fn pid_alive(_: i32) -> bool {
|
||||
|
||||
/// La conversation écrit-elle une réponse ? Celle de la façade si elle la
|
||||
/// tient, sinon celle du Go.
|
||||
async fn generating(ctx: &Ctx) -> bool {
|
||||
pub(crate) async fn generating(ctx: &Ctx) -> bool {
|
||||
if let Some(chat) = &ctx.chat {
|
||||
return chat.sessions.conv().is_generating();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod oai;
|
||||
pub mod paths;
|
||||
pub mod presets;
|
||||
pub mod presets_api;
|
||||
pub mod projects_api;
|
||||
pub mod proxy;
|
||||
pub mod push;
|
||||
pub mod serve;
|
||||
|
||||
@@ -154,6 +154,8 @@ fn serve() -> ExitCode {
|
||||
// LOKI_SYSTEM=rust : les interrupteurs système (accès internet,
|
||||
// navigateur, clé de complétion, écoute réseau du moteur).
|
||||
let system_rust = held_by_facade("LOKI_SYSTEM");
|
||||
// LOKI_PROJECTS=rust : le hub des projets.
|
||||
let projects_rust = held_by_facade("LOKI_PROJECTS");
|
||||
let engine_rust = held_by_facade("LOKI_ENGINE")
|
||||
&& match loki_rs::enginectl::usable(
|
||||
std::env::var("LOKI_CONTAINER").ok().as_deref(),
|
||||
@@ -182,6 +184,7 @@ fn serve() -> ExitCode {
|
||||
("LOKI_PRESETS", presets_rust),
|
||||
("LOKI_ENGINE", engine_rust),
|
||||
("LOKI_SYSTEM", system_rust),
|
||||
("LOKI_PROJECTS", projects_rust),
|
||||
];
|
||||
for (var, on) in held {
|
||||
if on {
|
||||
@@ -285,6 +288,7 @@ fn serve() -> ExitCode {
|
||||
presets: presets_rust,
|
||||
engine_ctl,
|
||||
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
|
||||
projects: projects_rust,
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
|
||||
@@ -48,7 +48,7 @@ pub const MEM_INDEX_FILE: &str = "MEMORY.md";
|
||||
/// Magic des contenus chiffrés (`memPageMagic`).
|
||||
pub const PAGE_MAGIC: &[u8] = b"LOKIMEMv1";
|
||||
pub const BK_TRACKER: &str = "tracker";
|
||||
const ST_PROJECTS: &str = "projects";
|
||||
pub(crate) const ST_PROJECTS: &str = "projects";
|
||||
const ST_ACTIVE_PROJECT: &str = "active_project";
|
||||
const ST_AGENT: &str = "agent";
|
||||
const DEFAULT_PROJECT_SLUG: &str = "generale";
|
||||
@@ -400,7 +400,7 @@ pub fn parse_when(when: &str, now_ms: i64, tz: Tz) -> Result<(i64, bool), String
|
||||
))
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
pub(crate) fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Hub des projets tenu par la façade (lot 18 de la migration,
|
||||
//! `LOKI_PROJECTS=rust` ; expérimental, le Go reste le défaut) — port de
|
||||
//! `handleProjects` / `projectDTOs` (`web_projects.go`) et des mutations de
|
||||
//! `projects.go` : création (slug translittéré, unique), renommage,
|
||||
//! description, suppression (discussions, trackers et mémoire du projet, actif
|
||||
//! réassigné, dernier projet indestructible) et bascule.
|
||||
//!
|
||||
//! Le registre reste dans la base (`state/projects`), la mémoire de chaque
|
||||
//! projet dans `memory/<slug>`. Le projet actif passe toujours par le Go
|
||||
//! (`/internal/v1/project/activate`), qui en garde une copie en RAM. Les
|
||||
//! discussions sont à qui tient la conversation : la façade les bascule et
|
||||
//! les supprime elle-même quand elle la tient, sinon le Go
|
||||
//! (`/internal/v1/project/switch`, `/internal/v1/project/drop-convs`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::api::Ctx;
|
||||
use crate::auth::json as send_json;
|
||||
use crate::chat::persist::{BK_CHAT, CK_INDEX, decode_index};
|
||||
use crate::gojson::{self, Kind};
|
||||
use crate::memory::{self, BK_TRACKER, Project, ST_PROJECTS, active_project, list_projects};
|
||||
use crate::paths::DEFAULT_PROJECT_SLUG;
|
||||
use crate::proxy::Body;
|
||||
use crate::store::{BK_STATE, Store};
|
||||
|
||||
/// Taille maximale d'un nom de projet, en runes.
|
||||
pub const MAX_NAME: usize = 60;
|
||||
/// Taille maximale d'une description, en runes.
|
||||
pub const MAX_DESC: usize = 2000;
|
||||
|
||||
/// Une erreur de route : (code, message).
|
||||
type Fail = (StatusCode, String);
|
||||
|
||||
fn bad(e: impl Into<String>) -> Fail {
|
||||
(StatusCode::BAD_REQUEST, e.into())
|
||||
}
|
||||
|
||||
/// Les `n` premières runes.
|
||||
fn runes(s: &str, n: usize) -> String {
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
|
||||
/// `saveProjects` (`json.Marshal`, HTML échappé comme le Go).
|
||||
async fn save_projects(store: &Store, list: &[Project]) -> Result<(), String> {
|
||||
store
|
||||
.put(BK_STATE, ST_PROJECTS, gojson::marshal(list).into_bytes())
|
||||
.await
|
||||
}
|
||||
|
||||
/// `projectMemoryDir`.
|
||||
pub fn project_memory_dir(home: &Path, slug: &str) -> std::path::PathBuf {
|
||||
let slug = if slug.is_empty() {
|
||||
DEFAULT_PROJECT_SLUG
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
home.join("memory").join(slug)
|
||||
}
|
||||
|
||||
/// Pages d'un projet (`MemList` sans l'index) : fichiers `.md` visibles.
|
||||
pub fn count_pages(dir: &Path) -> usize {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| {
|
||||
!n.starts_with('.') && n.to_lowercase().ends_with(".md") && !memory::is_index_file(n)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// `slugifyIdent` puis le repli « projet » et le suffixe -2, -3… d'un slug
|
||||
/// déjà pris.
|
||||
pub fn unique_slug(name: &str, list: &[Project]) -> String {
|
||||
let mut base = memory::slugify_ident(name);
|
||||
if base.is_empty() {
|
||||
base = "projet".into();
|
||||
}
|
||||
let taken = |s: &str| list.iter().any(|p| p.slug == s);
|
||||
let mut slug = base.clone();
|
||||
let mut i = 2;
|
||||
while taken(&slug) {
|
||||
slug = format!("{base}-{i}");
|
||||
i += 1;
|
||||
}
|
||||
slug
|
||||
}
|
||||
|
||||
/// `createProject`.
|
||||
async fn create_project(store: &Store, home: &Path, name: &str) -> Result<Project, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("nom vide".into());
|
||||
}
|
||||
let name = runes(name, MAX_NAME);
|
||||
let mut list = list_projects(store).await;
|
||||
let slug = unique_slug(&name, &list);
|
||||
let dir = project_memory_dir(home, &slug);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let idx = dir.join(memory::MEM_INDEX_FILE);
|
||||
if !idx.exists() {
|
||||
let _ = write_private(&idx, memory::memory_seed(&name).as_bytes());
|
||||
}
|
||||
let p = Project {
|
||||
slug,
|
||||
name,
|
||||
created_at: memory::now_ms(),
|
||||
desc: String::new(),
|
||||
mem_mode: String::new(),
|
||||
};
|
||||
list.push(p.clone());
|
||||
save_projects(store, &list).await?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Fichier en 0600, comme `os.WriteFile(…, 0o600)`.
|
||||
fn write_private(p: &Path, b: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.mode(0o600);
|
||||
}
|
||||
o.open(p)?.write_all(b)
|
||||
}
|
||||
|
||||
/// `renameProject`.
|
||||
async fn rename_project(store: &Store, slug: &str, name: &str) -> Result<(), String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("nom vide".into());
|
||||
}
|
||||
let name = runes(name, MAX_NAME);
|
||||
let mut list = list_projects(store).await;
|
||||
let Some(p) = list.iter_mut().find(|p| p.slug == slug) else {
|
||||
return Err("projet introuvable".into());
|
||||
};
|
||||
p.name = name;
|
||||
save_projects(store, &list).await
|
||||
}
|
||||
|
||||
/// `setProjectDesc` : tronquée à 2 000 runes (contexte injecté à chaque
|
||||
/// conversation, pas un cahier des charges) ; vide = effacée.
|
||||
async fn set_project_desc(store: &Store, slug: &str, desc: &str) -> Result<(), String> {
|
||||
let mut desc = desc.trim().to_string();
|
||||
if desc.chars().count() > MAX_DESC {
|
||||
desc = runes(&desc, MAX_DESC).trim().to_string();
|
||||
}
|
||||
let mut list = list_projects(store).await;
|
||||
let Some(p) = list.iter_mut().find(|p| p.slug == slug) else {
|
||||
return Err("projet introuvable".into());
|
||||
};
|
||||
p.desc = desc;
|
||||
save_projects(store, &list).await
|
||||
}
|
||||
|
||||
/// Le projet actif, par le Go (qui en garde une copie en RAM).
|
||||
async fn activate(store: &Store, slug: &str) -> Result<(), Fail> {
|
||||
let (code, v) = store
|
||||
.internal_post("/internal/v1/project/activate", &json!({ "slug": slug }))
|
||||
.await
|
||||
.map_err(bad)?;
|
||||
if code.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(go_fail(code, &v))
|
||||
}
|
||||
|
||||
/// Une réponse d'erreur du Go : 409 gardé, le reste en 400.
|
||||
fn go_fail(code: StatusCode, v: &Value) -> Fail {
|
||||
let msg = v
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| code.to_string());
|
||||
let code = if code == StatusCode::CONFLICT {
|
||||
StatusCode::CONFLICT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, msg)
|
||||
}
|
||||
|
||||
fn refused(r: crate::chat::generate::Refused) -> Fail {
|
||||
let code = if r.status == 409 {
|
||||
StatusCode::CONFLICT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, r.error)
|
||||
}
|
||||
|
||||
/// Bascule de la conversation sur un projet : la façade si elle la tient,
|
||||
/// sinon le Go.
|
||||
async fn switch_conversation(ctx: &Ctx, slug: &str) -> Result<(), Fail> {
|
||||
if let Some(chat) = &ctx.chat {
|
||||
return chat.project_switch(slug).await.map_err(refused);
|
||||
}
|
||||
let (code, v) = ctx
|
||||
.store
|
||||
.internal_post("/internal/v1/project/switch", &json!({ "slug": slug }))
|
||||
.await
|
||||
.map_err(bad)?;
|
||||
if code.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(go_fail(code, &v))
|
||||
}
|
||||
}
|
||||
|
||||
/// `deleteProject`.
|
||||
async fn delete_project(ctx: &Ctx, slug: &str) -> Result<(), Fail> {
|
||||
let store = &ctx.store;
|
||||
let list = list_projects(store).await;
|
||||
if list.len() <= 1 {
|
||||
return Err(bad("impossible de supprimer le dernier projet"));
|
||||
}
|
||||
let kept: Vec<Project> = list.iter().filter(|p| p.slug != slug).cloned().collect();
|
||||
if kept.len() == list.len() {
|
||||
return Err(bad("projet introuvable"));
|
||||
}
|
||||
// Discussions du projet. Quand la façade tient la conversation, elle
|
||||
// quitte d'abord le projet s'il est actif (sa discussion enregistrée, une
|
||||
// du projet restant ouverte), puis supprime les autres ; sinon le Go.
|
||||
if let Some(chat) = &ctx.chat {
|
||||
if active_project(store).await == slug {
|
||||
chat.project_switch(&kept[0].slug).await.map_err(refused)?;
|
||||
}
|
||||
chat.drop_project(slug).await.map_err(refused)?;
|
||||
} else {
|
||||
let (code, v) = store
|
||||
.internal_post("/internal/v1/project/drop-convs", &json!({ "slug": slug }))
|
||||
.await
|
||||
.map_err(bad)?;
|
||||
if !code.is_success() {
|
||||
return Err(go_fail(code, &v));
|
||||
}
|
||||
}
|
||||
// Trackers du projet.
|
||||
let prefix = format!("{slug}/");
|
||||
if let Ok(all) = store.all(BK_TRACKER).await {
|
||||
for k in all.keys().filter(|k| k.starts_with(&prefix)) {
|
||||
let _ = store.del(BK_TRACKER, k).await;
|
||||
}
|
||||
}
|
||||
// Dossier disque du projet.
|
||||
let _ = std::fs::remove_dir_all(project_memory_dir(&ctx.engine.home, slug));
|
||||
save_projects(store, &kept).await.map_err(bad)?;
|
||||
if active_project(store).await == slug {
|
||||
let _ = activate(store, &kept[0].slug).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `projectDTOs` : le registre et ses comptes, plus le projet actif.
|
||||
async fn listing(ctx: &Ctx) -> Value {
|
||||
let store = &ctx.store;
|
||||
let active = active_project(store).await;
|
||||
let mut convs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
if let Ok(Some(b)) = store.get_open(BK_CHAT, CK_INDEX).await {
|
||||
for m in decode_index(&b) {
|
||||
let p = match m.project.trim() {
|
||||
"" => DEFAULT_PROJECT_SLUG.to_string(),
|
||||
s => s.to_string(),
|
||||
};
|
||||
*convs.entry(p).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let trackers: Vec<String> = store
|
||||
.all(BK_TRACKER)
|
||||
.await
|
||||
.map(|m| m.into_keys().collect())
|
||||
.unwrap_or_default();
|
||||
let home = ctx.engine.home.clone();
|
||||
let list = list_projects(store).await;
|
||||
let pages: Vec<usize> = {
|
||||
let dirs: Vec<_> = list
|
||||
.iter()
|
||||
.map(|p| project_memory_dir(&home, &p.slug))
|
||||
.collect();
|
||||
tokio::task::spawn_blocking(move || dirs.iter().map(|d| count_pages(d)).collect())
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let projects: Vec<Value> = list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let prefix = format!("{}/", p.slug);
|
||||
let mut d = json!({
|
||||
"slug": p.slug,
|
||||
"name": p.name,
|
||||
"created": p.created_at,
|
||||
"convs": convs.get(&p.slug).copied().unwrap_or(0),
|
||||
"pages": pages.get(i).copied().unwrap_or(0),
|
||||
"trackers": trackers.iter().filter(|k| k.starts_with(&prefix)).count(),
|
||||
"active": p.slug == active,
|
||||
});
|
||||
if !p.desc.is_empty() {
|
||||
d["desc"] = json!(p.desc);
|
||||
}
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
json!({ "ok": true, "projects": projects, "active": active })
|
||||
}
|
||||
|
||||
/// Les routes servies par ce module.
|
||||
pub fn handles(path: &str) -> bool {
|
||||
path == "/api/projects"
|
||||
}
|
||||
|
||||
const PROJECT_REQ_TYPE: &str = r#"struct { Action string "json:\"action\""; Slug string "json:\"slug\""; Name string "json:\"name\""; Desc string "json:\"desc\"" }"#;
|
||||
|
||||
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
|
||||
pub async fn serve(ctx: &Ctx, req: Request<Incoming>, from_go: bool) -> Response<Body> {
|
||||
let (parts, body) = req.into_parts();
|
||||
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
|
||||
return *refus;
|
||||
}
|
||||
match parts.method {
|
||||
Method::GET => send_json(StatusCode::OK, &listing(ctx).await),
|
||||
Method::POST => {
|
||||
let raw = Limited::new(body, 1 << 20)
|
||||
.collect()
|
||||
.await
|
||||
.map(|b| b.to_bytes().to_vec())
|
||||
.unwrap_or_default();
|
||||
let d = gojson::decode_struct(
|
||||
&raw,
|
||||
PROJECT_REQ_TYPE,
|
||||
&[
|
||||
("action", Kind::Str),
|
||||
("slug", Kind::Str),
|
||||
("name", Kind::Str),
|
||||
("desc", Kind::Str),
|
||||
],
|
||||
);
|
||||
if let Some(e) = d.err.clone() {
|
||||
return send_json(StatusCode::BAD_REQUEST, &json!({ "ok": false, "error": e }));
|
||||
}
|
||||
let slug = d.str("slug").trim().to_string();
|
||||
let store = &ctx.store;
|
||||
let home = ctx.engine.home.clone();
|
||||
let action = d.str("action");
|
||||
let res: Result<(), Fail> = match action.trim().to_lowercase().as_str() {
|
||||
"create" => create_project(store, &home, &d.str("name"))
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(bad),
|
||||
"rename" => rename_project(store, &slug, &d.str("name"))
|
||||
.await
|
||||
.map_err(bad),
|
||||
"desc" => set_project_desc(store, &slug, &d.str("desc"))
|
||||
.await
|
||||
.map_err(bad),
|
||||
"delete" => {
|
||||
// Une génération travaille dans la mémoire du projet actif :
|
||||
// supprimer sous ses pieds la laisserait écrire dans le vide.
|
||||
if crate::enginectl::generating(ctx).await {
|
||||
Err((StatusCode::CONFLICT, "une génération est en cours".into()))
|
||||
} else {
|
||||
delete_project(ctx, &slug).await
|
||||
}
|
||||
}
|
||||
"switch" => {
|
||||
if crate::enginectl::generating(ctx).await {
|
||||
Err((StatusCode::CONFLICT, "une génération est en cours".into()))
|
||||
} else if ctx.chat.is_some()
|
||||
&& !list_projects(store).await.iter().any(|p| p.slug == slug)
|
||||
{
|
||||
Err(bad("projet introuvable"))
|
||||
} else {
|
||||
switch_conversation(ctx, &slug).await
|
||||
}
|
||||
}
|
||||
_ => Err(bad(format!("action inconnue : {}", gojson::quote(&action)))),
|
||||
};
|
||||
match res {
|
||||
Ok(()) => send_json(StatusCode::OK, &listing(ctx).await),
|
||||
Err((code, e)) => send_json(code, &json!({ "ok": false, "error": e })),
|
||||
}
|
||||
}
|
||||
_ => send_json(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
&json!({ "ok": false, "error": "méthode non autorisée" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(slug: &str) -> Project {
|
||||
Project {
|
||||
slug: slug.into(),
|
||||
name: slug.into(),
|
||||
created_at: 0,
|
||||
desc: String::new(),
|
||||
mem_mode: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_uniques() {
|
||||
let list = vec![p("alpha"), p("alpha-2"), p("projet")];
|
||||
assert_eq!(unique_slug("Alpha", &list), "alpha-3");
|
||||
assert_eq!(unique_slug("!!!", &list), "projet-2");
|
||||
assert_eq!(unique_slug("Bêta", &list), "beta");
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,8 @@ pub struct Config {
|
||||
/// Interrupteurs système (internet, navigateur, clé de complétion,
|
||||
/// réseau) tenus par la façade (`LOKI_SYSTEM=rust`) ; `None` : au Go.
|
||||
pub system: Option<Arc<crate::system::System>>,
|
||||
/// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
|
||||
pub projects: bool,
|
||||
}
|
||||
|
||||
struct Proxy {
|
||||
@@ -121,6 +123,7 @@ pub async fn serve(
|
||||
presets: cfg.presets,
|
||||
engine_ctl: cfg.engine_ctl,
|
||||
system: cfg.system,
|
||||
projects: cfg.projects,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
@@ -208,6 +211,9 @@ impl Proxy {
|
||||
{
|
||||
return chatapi::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
if self.ctx.projects && crate::projects_api::handles(path) {
|
||||
return crate::projects_api::serve(&self.ctx, req, from_go).await;
|
||||
}
|
||||
if let Some(s) = &self.ctx.system
|
||||
&& crate::system::handles(path)
|
||||
{
|
||||
|
||||
@@ -321,6 +321,59 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
st.set("state", "active_project", &slug);
|
||||
resp(204, "")
|
||||
}
|
||||
// Conversation tenue par le Go (lot 18) : bascule (projectSwitch) et
|
||||
// discussions d'un projet supprimé (convDelete), sur l'index en base.
|
||||
"/internal/v1/project/switch" | "/internal/v1/project/drop-convs" => {
|
||||
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().trim().to_string();
|
||||
if *st.generating.lock().unwrap() {
|
||||
return resp(409, r#"{"error":"une génération est en cours"}"#);
|
||||
}
|
||||
let mut idx: Vec<serde_json::Value> = st
|
||||
.get("chat", "index")
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let proj = |m: &serde_json::Value| match m["project"].as_str().unwrap_or("").trim() {
|
||||
"" => "generale".to_string(),
|
||||
s => s.to_string(),
|
||||
};
|
||||
if path.ends_with("drop-convs") {
|
||||
let gone: Vec<String> = idx
|
||||
.iter()
|
||||
.filter(|m| proj(m) == slug)
|
||||
.map(|m| m["id"].as_str().unwrap_or("").to_string())
|
||||
.collect();
|
||||
idx.retain(|m| proj(m) != slug);
|
||||
st.set("chat", "index", &serde_json::to_string(&idx).unwrap());
|
||||
for id in &gone {
|
||||
st.del("chat", &format!("conv:{id}"));
|
||||
}
|
||||
return resp(
|
||||
200,
|
||||
serde_json::json!({ "dropped": gone.len() }).to_string(),
|
||||
);
|
||||
}
|
||||
let known = st
|
||||
.get("state", "projects")
|
||||
.and_then(|s| serde_json::from_str::<Vec<serde_json::Value>>(&s).ok())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|p| p["slug"] == slug.as_str());
|
||||
if !known {
|
||||
return resp(400, r#"{"error":"projet introuvable"}"#);
|
||||
}
|
||||
if st.get("state", "active_project").as_deref() == Some(slug.as_str()) {
|
||||
return resp(204, "");
|
||||
}
|
||||
st.set("state", "active_project", &slug);
|
||||
if !idx.iter().any(|m| proj(m) == slug) {
|
||||
let id = format!("c{}", idx.len() + 1_000_000);
|
||||
idx.push(serde_json::json!({ "id": id, "title": "", "created": 0, "updated": 0, "turns": 0, "project": slug }));
|
||||
st.set("chat", "index", &serde_json::to_string(&idx).unwrap());
|
||||
}
|
||||
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();
|
||||
@@ -579,6 +632,7 @@ pub struct Opts {
|
||||
pub presets: bool,
|
||||
pub engine_ctl: Option<Arc<loki_rs::enginectl::EngineCtl>>,
|
||||
pub system: Option<Arc<loki_rs::system::System>>,
|
||||
pub projects: bool,
|
||||
}
|
||||
|
||||
/// Comme [`start`], avec la conversation tenue par la façade
|
||||
@@ -677,6 +731,7 @@ pub async fn start_with(
|
||||
presets: opts.presets,
|
||||
engine_ctl: opts.engine_ctl,
|
||||
system: opts.system,
|
||||
projects: opts.projects,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -103,6 +103,7 @@ async fn banc(cle: Option<&str>) -> Banc {
|
||||
presets: false,
|
||||
engine_ctl: None,
|
||||
system: None,
|
||||
projects: false,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,223 @@
|
||||
//! Lot 18 : hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
|
||||
//!
|
||||
//! Le journal tiré du Go (`internal/loki/parity_projects_test.go` →
|
||||
//! `tests/parity_projects/projects.json`) est rejoué par la façade, la
|
||||
//! conversation tenue par le (faux) Go : liste et comptes, création,
|
||||
//! renommage, description, bascule, suppression, refus et erreurs, avec après
|
||||
//! chaque appel le registre, le projet actif, l'arbre memory/, les trackers et
|
||||
//! l'index des discussions. La bascule et la suppression quand la façade tient
|
||||
//! la conversation sont vérifiées de bout en bout.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use common::fakego::{self, CryptMode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("parity_projects/projects.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap()
|
||||
}
|
||||
|
||||
/// Les dates propres à chaque exécution, à zéro (comme le journal).
|
||||
fn zero_times(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
if matches!(k.as_str(), "created" | "created_at" | "updated") {
|
||||
*x = json!(0);
|
||||
} else {
|
||||
zero_times(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(zero_times),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn tree(home: &Path) -> Vec<String> {
|
||||
fn walk(root: &Path, p: &Path, out: &mut Vec<String>) {
|
||||
let rel = p.strip_prefix(root).unwrap().to_string_lossy().into_owned();
|
||||
if p.is_dir() {
|
||||
out.push(format!("{rel}/"));
|
||||
for e in std::fs::read_dir(p).unwrap().flatten() {
|
||||
walk(root, &e.path(), out);
|
||||
}
|
||||
} else {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if home.join("memory").exists() {
|
||||
walk(home, &home.join("memory"), &mut out);
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn snapshot(go: &fakego::FakeGo, home: &Path, seen: &BTreeSet<String>) -> Value {
|
||||
let mut reg: Value = go
|
||||
.get("state", "projects")
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
zero_times(&mut reg);
|
||||
let st = go.store.lock().unwrap();
|
||||
let mut trackers: Vec<String> = st
|
||||
.get("tracker")
|
||||
.map(|b| b.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
trackers.sort();
|
||||
drop(st);
|
||||
let idx: Vec<Value> = go
|
||||
.get("chat", "index")
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut convs: Vec<String> = idx
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let id = s(&m["id"]);
|
||||
let id = if seen.contains(id) { id } else { "{{NEW}}" };
|
||||
format!("{id}:{}", m["project"].as_str().unwrap_or(""))
|
||||
})
|
||||
.collect();
|
||||
convs.sort();
|
||||
json!({
|
||||
"projects": reg,
|
||||
"active": go.get("state", "active_project").unwrap_or_default(),
|
||||
"tree": tree(home),
|
||||
"trackers": trackers,
|
||||
"convs": convs,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parite_du_hub() {
|
||||
let fx = fixture();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
for (rel, body) in fx["seed_mem"].as_object().unwrap() {
|
||||
let p = home.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, s(body)).unwrap();
|
||||
}
|
||||
// Le dossier vide de la graine (sous/ a une page : rien d'autre à créer).
|
||||
let (facade, go, _stop, _) = fakego::start_with(
|
||||
home.clone(),
|
||||
fakego::Opts {
|
||||
projects: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Plain;
|
||||
for (k, v) in fx["seed_store"].as_object().unwrap() {
|
||||
let (b, key) = k.split_once('/').unwrap();
|
||||
go.set(b, key, s(v));
|
||||
}
|
||||
let seen: BTreeSet<String> =
|
||||
serde_json::from_str::<Vec<Value>>(s(&fx["seed_store"]["chat/index"]))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| s(&m["id"]).to_string())
|
||||
.collect();
|
||||
assert_eq!(snapshot(&go, &home, &seen), fx["seed"], "graine");
|
||||
for st in fx["steps"].as_array().unwrap() {
|
||||
let name = s(&st["name"]);
|
||||
*go.generating.lock().unwrap() = st["generating"].as_bool() == Some(true);
|
||||
let (code, body) = fakego::send(
|
||||
facade,
|
||||
s(&st["method"]),
|
||||
"/api/projects",
|
||||
&[],
|
||||
st["body"].as_str().unwrap_or(""),
|
||||
)
|
||||
.await;
|
||||
*go.generating.lock().unwrap() = false;
|
||||
assert_eq!(
|
||||
code.as_u16() as i64,
|
||||
st["status"].as_i64().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
let mut out: Value = serde_json::from_slice(&body).unwrap();
|
||||
zero_times(&mut out);
|
||||
assert_eq!(out, st["out"], "{name}");
|
||||
assert_eq!(snapshot(&go, &home, &seen), st["after"], "{name} : état");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bascule_et_suppression_par_la_facade() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let (facade, go, _stop, chat) = fakego::start_with(
|
||||
home.clone(),
|
||||
fakego::Opts {
|
||||
chat: true,
|
||||
projects: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let chat = chat.unwrap();
|
||||
let post = |body: &'static str| async move {
|
||||
let (c, b) = fakego::send(facade, "POST", "/api/projects", &[], body).await;
|
||||
(c.as_u16(), serde_json::from_slice::<Value>(&b).unwrap())
|
||||
};
|
||||
let (c, _) = post(r#"{"action":"create","name":"Alpha"}"#).await;
|
||||
assert_eq!(c, 200);
|
||||
let (c, v) = post(r#"{"action":"switch","slug":"alpha"}"#).await;
|
||||
assert_eq!((c, v["active"].clone()), (200, json!("alpha")));
|
||||
// La conversation de la façade a suivi : une discussion d'Alpha ouverte.
|
||||
let idx = chat.sessions.read_index().await;
|
||||
let open = chat.sessions.conv().id();
|
||||
assert!(
|
||||
idx.iter().any(|m| m.id == open && m.project == "alpha"),
|
||||
"{idx:?}"
|
||||
);
|
||||
assert!(
|
||||
go.conv_log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"project alpha".to_string())
|
||||
);
|
||||
// Projet introuvable : refusé par la façade, sans toucher à la conversation.
|
||||
let (c, v) = post(r#"{"action":"switch","slug":"nope"}"#).await;
|
||||
assert_eq!((c, v["error"].clone()), (400, json!("projet introuvable")));
|
||||
// Suppression du projet actif : la façade revient sur Générale et supprime
|
||||
// les discussions d'Alpha ; le Go n'y touche pas.
|
||||
let (c, v) = post(r#"{"action":"delete","slug":"alpha"}"#).await;
|
||||
assert_eq!((c, v["active"].clone()), (200, json!("generale")), "{v}");
|
||||
let idx = chat.sessions.read_index().await;
|
||||
assert!(idx.iter().all(|m| m.project != "alpha"), "{idx:?}");
|
||||
assert!(!home.join("memory/alpha").exists());
|
||||
// Pendant un tour de la façade : 409.
|
||||
let (c, _) = post(r#"{"action":"create","name":"Beta"}"#).await;
|
||||
assert_eq!(c, 200);
|
||||
let ep = chat.sessions.conv().begin_turn(Box::new(|| {})).unwrap();
|
||||
let (c, v) = post(r#"{"action":"delete","slug":"beta"}"#).await;
|
||||
assert_eq!(
|
||||
(c, v["error"].clone()),
|
||||
(409, json!("une génération est en cours"))
|
||||
);
|
||||
let (c, _) = post(r#"{"action":"switch","slug":"beta"}"#).await;
|
||||
assert_eq!(c, 409);
|
||||
// Le tour passe avant tout le reste, projet inconnu compris (comme quand
|
||||
// le Go tient la conversation).
|
||||
let (c, _) = post(r#"{"action":"switch","slug":"nope"}"#).await;
|
||||
assert_eq!(c, 409);
|
||||
chat.sessions.conv().release_turn(ep);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sans_la_variable_le_hub_reste_au_go() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
||||
let (_, _, b) = fakego::get(facade, "/api/projects", &[]).await;
|
||||
assert_eq!(&b[..], b"go:/api/projects");
|
||||
}
|
||||
@@ -109,6 +109,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
presets: false,
|
||||
engine_ctl: None,
|
||||
system: None,
|
||||
projects: false,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
Reference in new issue
Block a user