mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +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
@@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Lot 18 — hub des projets tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le hub des projets vivait dans le Go :
|
||||
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
|
||||
- les mutations de `projects.go` : `createProject`, `renameProject`,
|
||||
`setProjectDesc`, `deleteProject` ;
|
||||
- `projectSwitch` (`chat_sessions.go`).
|
||||
|
||||
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
|
||||
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Action | Rôle |
|
||||
|---|---|
|
||||
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
|
||||
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
|
||||
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
|
||||
| `desc` | 2 000 runes au plus, vide = effacée |
|
||||
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
|
||||
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
|
||||
|
||||
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
|
||||
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
|
||||
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
|
||||
|
||||
Les discussions sont à qui tient la conversation.
|
||||
|
||||
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
|
||||
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
|
||||
et en ouvre une du premier projet restant.
|
||||
|
||||
Sinon le Go garde deux routes internes (`facade_projects.go`) :
|
||||
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
|
||||
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
|
||||
- `POST /internal/v1/project/drop-convs` : les discussions du projet
|
||||
supprimées (`convDelete`, fichiers et résultats compris).
|
||||
|
||||
Une génération en cours refuse la bascule et la suppression (409). La façade
|
||||
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
|
||||
la façade lit le projet actif enregistré.
|
||||
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
|
||||
comme quand le Go tient la conversation. Le Go, relayant à la façade,
|
||||
répondait 400 (« projet introuvable ») dans ce cas.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_projects_test.go` part d'une base amorcée :
|
||||
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
|
||||
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
|
||||
projet disparu ;
|
||||
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
|
||||
|
||||
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
|
||||
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
|
||||
- créations (doublons, translittération, sans lettres, longues, vides) ;
|
||||
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
|
||||
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
|
||||
une génération) ;
|
||||
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
|
||||
dernier projet) ;
|
||||
- actions inconnues, corps vide ou illisible, erreur de type, méthode
|
||||
refusée.
|
||||
|
||||
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
|
||||
bascule et supprime les discussions comme le vrai. La bascule et la
|
||||
suppression quand la façade tient la conversation sont vérifiées de bout en
|
||||
bout. Une mutation de chaque règle fait échouer les tests.
|
||||
|
||||
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
|
||||
@@ -145,7 +145,8 @@ var facadeChatRoutes = map[string]bool{
|
||||
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
|
||||
// de la discussion (facade_files.go), les notifications (facade_push.go), la
|
||||
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
|
||||
// moteur (facade_engine.go), les interrupteurs système (facade_system.go).
|
||||
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
|
||||
// projets (facade_projects.go).
|
||||
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
|
||||
// jeton interne qui en tient lieu là-bas.
|
||||
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -170,6 +171,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
held, unavailable = facadeEngine, "pilotage du moteur indisponible : "
|
||||
case facadeSystemRoutes[path]:
|
||||
held, unavailable = facadeSystem, "réglages système indisponibles : "
|
||||
case facadeProjectsRoutes[path]:
|
||||
held, unavailable = facadeProjects, "projets indisponibles : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -758,3 +758,72 @@ func TestFacadeRelaisGardeHote(t *testing.T) {
|
||||
t.Fatalf("hôte %q, schéma %q", host, proto)
|
||||
}
|
||||
}
|
||||
|
||||
// Le hub des projets (LOKI_PROJECTS=rust, lot 18) part à la façade ; quand le
|
||||
// Go tient la conversation, la bascule et les discussions d'un projet
|
||||
// supprimé restent ici (API interne).
|
||||
func TestFacadeProjets(t *testing.T) {
|
||||
testHome(t)
|
||||
// Le projet actif est gardé en RAM d'un test à l'autre.
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
})
|
||||
ensureDefaultProject()
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("GET", "/api/projects", nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("servi ailleurs sans LOKI_PROJECTS : %q", w.Body.String())
|
||||
}
|
||||
t.Setenv(facadeProjectsEnv, "rust")
|
||||
w = httptest.NewRecorder()
|
||||
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader("{}")))
|
||||
if w.Body.String() != f.reply || len(f.recu()) != 1 {
|
||||
t.Fatalf("relais : %q, %d appels", w.Body.String(), len(f.recu()))
|
||||
}
|
||||
|
||||
if _, err := createProject("Alpha"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convIndexSave([]convMeta{{ID: "a1", Project: "alpha", Updated: 1}, {ID: "g1", Project: "generale", Updated: 2}})
|
||||
_ = putBytes(bkChat, convKey("a1"), []byte(`{"id":"a1"}`))
|
||||
_ = putBytes(bkChat, convKey("g1"), []byte(`{"id":"g1"}`))
|
||||
post := func(h http.HandlerFunc, body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest("POST", "/internal/v1/project/x", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if c, b := post(handleInternalProjectSwitch, `{"slug":"nope"}`); c != 400 || b != `{"error":"projet introuvable"}` {
|
||||
t.Fatalf("bascule inconnue : %d %s", c, b)
|
||||
}
|
||||
if c, _ := post(handleInternalProjectSwitch, `{"slug":" alpha "}`); c != 204 || activeProjectSlug() != "alpha" {
|
||||
t.Fatalf("bascule : %d, actif %q", c, activeProjectSlug())
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
conv.mu.Unlock()
|
||||
c1, _ := post(handleInternalProjectSwitch, `{"slug":"generale"}`)
|
||||
c2, _ := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`)
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
if c1 != 409 || c2 != 409 {
|
||||
t.Fatalf("pendant une génération : %d, %d", c1, c2)
|
||||
}
|
||||
if c, b := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`); c != 200 || b != `{"dropped":1}` {
|
||||
t.Fatalf("discussions supprimées : %d %s", c, b)
|
||||
}
|
||||
if n := len(convIndexForProject("alpha")); n != 0 || len(convIndexForProject("generale")) == 0 {
|
||||
t.Fatalf("index après : %d d'Alpha", n)
|
||||
}
|
||||
if c, _ := post(handleInternalProjectDropConvs, `nimp`); c != 400 {
|
||||
t.Fatalf("corps illisible : %d", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_projects.go — hub des projets tenu par la façade Rust
|
||||
// (LOKI_PROJECTS=rust, lot 18 de la migration ; expérimental, le Go reste le
|
||||
// défaut) : liste avec ses comptes, création, renommage, description,
|
||||
// suppression, bascule (/api/projects).
|
||||
//
|
||||
// Le registre reste dans la base (state/projects) et 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. Quand le Go
|
||||
// tient la conversation, ce qui la touche reste ici :
|
||||
//
|
||||
// - la bascule : discussion du projet quitté enregistrée, la plus récente
|
||||
// du projet rejoint ouverte (/internal/v1/project/switch) ;
|
||||
// - les discussions d'un projet supprimé (/internal/v1/project/drop-convs).
|
||||
//
|
||||
// Quand la façade tient la conversation, elle fait les deux elle-même.
|
||||
|
||||
const facadeProjectsEnv = "LOKI_PROJECTS"
|
||||
|
||||
// facadeProjects : la façade tient-elle le hub des projets ?
|
||||
func facadeProjects() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeProjectsEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeProjectsRoutes : les routes que la façade sert alors (projects_api.rs).
|
||||
var facadeProjectsRoutes = map[string]bool{
|
||||
"/api/projects": true,
|
||||
}
|
||||
|
||||
// handleInternalProjectSwitch : POST {slug} → 204, ou 400/409 + {error}.
|
||||
func handleInternalProjectSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if conv.isGenerating() {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"})
|
||||
return
|
||||
}
|
||||
if err := projectSwitch(strings.TrimSpace(in.Slug)); err != nil {
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleInternalProjectDropConvs : POST {slug} → 200 {dropped} — supprime les
|
||||
// discussions du projet, comme deleteProject quand le Go tient la conversation.
|
||||
func handleInternalProjectDropConvs(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if conv.isGenerating() {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"})
|
||||
return
|
||||
}
|
||||
n := 0
|
||||
for _, m := range convIndexForProject(strings.TrimSpace(in.Slug)) {
|
||||
if convDelete(m.ID) == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"dropped": n})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parity_projects_test.go — journal de référence du hub des projets (lot 18
|
||||
// de la migration Rust) : liste et comptes (discussions, pages, trackers),
|
||||
// création (slug translittéré, unique, tronqué), renommage, description,
|
||||
// bascule et suppression (discussions, trackers et mémoire du projet, actif
|
||||
// réassigné, dernier projet indestructible), refus pendant une génération,
|
||||
// erreurs JSON. Après chaque appel : le registre, le projet actif, l'arbre
|
||||
// memory/, les trackers et l'index des discussions. La conversation est tenue
|
||||
// par le Go (le Rust le rejoue contre le faux Go, qui fait de même).
|
||||
// Rangé dans rust/loki-rs/tests/parity_projects/projects.json ;
|
||||
// tests/projects_api.rs le rejoue par la façade.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteProjets
|
||||
|
||||
const parityProjectsFile = "../../rust/loki-rs/tests/parity_projects/projects.json"
|
||||
|
||||
type parityProjectStep struct {
|
||||
Name string `json:"name"`
|
||||
Generating bool `json:"generating,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Out any `json:"out"`
|
||||
After map[string]any `json:"after"`
|
||||
}
|
||||
|
||||
// parityZeroTimes : les dates de création, propres à chaque exécution.
|
||||
func parityZeroTimes(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for k, x := range t {
|
||||
if k == "created" || k == "created_at" || k == "updated" {
|
||||
t[k] = 0
|
||||
} else {
|
||||
t[k] = parityZeroTimes(x)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i := range t {
|
||||
t[i] = parityZeroTimes(t[i])
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestPariteProjets(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ensureDefaultProject()
|
||||
// Le projet actif est gardé en RAM d'un test à l'autre.
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
})
|
||||
// Date de création fixe : la graine est rangée telle quelle.
|
||||
if err := saveProjects([]Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pages de Générale : trois comptées, le reste non.
|
||||
for _, f := range []string{"a.md", "b.MD", "z.md", ".cache.md", "notes.txt", "sous/page.md"} {
|
||||
p := filepath.Join(projectMemoryDir(defaultProjectSlug), f)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, []byte("# "+f+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Discussions : deux de Générale (dont une d'avant les projets), trois
|
||||
// d'Alpha (créé plus loin), une d'un projet disparu.
|
||||
idx := []convMeta{
|
||||
{ID: "g1", Title: "g1", Created: 1, Updated: 10, Project: "generale"},
|
||||
{ID: "o1", Title: "o1", Created: 2, Updated: 20},
|
||||
{ID: "a1", Title: "a1", Created: 3, Updated: 30, Project: "alpha"},
|
||||
{ID: "a2", Title: "a2", Created: 4, Updated: 50, Project: "alpha"},
|
||||
{ID: "a3", Title: "a3", Created: 5, Updated: 40, Project: "alpha"},
|
||||
{ID: "x1", Title: "x1", Created: 6, Updated: 60, Project: "ailleurs"},
|
||||
}
|
||||
convIndexSave(idx)
|
||||
for _, m := range idx {
|
||||
_ = putBytes(bkChat, convKey(m.ID), []byte(`{"id":"`+m.ID+`","log":[]}`))
|
||||
}
|
||||
// La discussion ouverte, comme au démarrage du Go (convEnsureActive).
|
||||
_ = putStr(bkChat, ckActive, "g1")
|
||||
convActivate("g1", getBytes(bkChat, convKey("g1")))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range idx {
|
||||
seen[m.ID] = true
|
||||
}
|
||||
for _, k := range []string{"generale/poids", "alpha/sommeil", "alpha/pas", "alphabet/x"} {
|
||||
_ = putStr(bkTracker, k, `{"name":"`+k+`","events":[]}`)
|
||||
}
|
||||
|
||||
snapshot := func() map[string]any {
|
||||
st := map[string]any{}
|
||||
var reg any
|
||||
_ = json.Unmarshal(getBytes(bkState, stProjectsKey), ®)
|
||||
st["projects"] = parityZeroTimes(reg)
|
||||
st["active"] = getStr(bkState, stActiveProjectKey)
|
||||
var tree []string
|
||||
_ = filepath.Walk(filepath.Join(home, "memory"), func(p string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(home, p)
|
||||
if fi.IsDir() {
|
||||
rel += "/"
|
||||
}
|
||||
tree = append(tree, rel)
|
||||
return nil
|
||||
})
|
||||
sort.Strings(tree)
|
||||
st["tree"] = tree
|
||||
var trk []string
|
||||
for k := range allKV(bkTracker) {
|
||||
trk = append(trk, k)
|
||||
}
|
||||
sort.Strings(trk)
|
||||
st["trackers"] = trk
|
||||
var convs []string
|
||||
for _, m := range convIndex() {
|
||||
id := m.ID
|
||||
if !seen[id] {
|
||||
id = "{{NEW}}" // discussion créée par la bascule, identifiant tiré au hasard
|
||||
}
|
||||
convs = append(convs, id+":"+m.Project)
|
||||
}
|
||||
sort.Strings(convs)
|
||||
st["convs"] = convs
|
||||
return st
|
||||
}
|
||||
seed := snapshot()
|
||||
seedMem := map[string]string{}
|
||||
_ = filepath.Walk(filepath.Join(home, "memory"), func(p string, fi os.FileInfo, err error) error {
|
||||
if err == nil && !fi.IsDir() {
|
||||
b, _ := os.ReadFile(p)
|
||||
rel, _ := filepath.Rel(home, p)
|
||||
seedMem[rel] = string(b)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
seedStore := map[string]string{
|
||||
"state/projects": string(getBytes(bkState, stProjectsKey)),
|
||||
"state/active_project": getStr(bkState, stActiveProjectKey),
|
||||
"chat/index": string(getBytes(bkChat, ckIndex)),
|
||||
}
|
||||
for _, m := range idx {
|
||||
seedStore["chat/"+convKey(m.ID)] = string(getBytes(bkChat, convKey(m.ID)))
|
||||
}
|
||||
for k, v := range allKV(bkTracker) {
|
||||
seedStore["tracker/"+k] = string(v)
|
||||
}
|
||||
|
||||
long := strings.Repeat("é", 2100) + " fin"
|
||||
var steps []parityProjectStep
|
||||
for _, st := range []parityProjectStep{
|
||||
{Name: "liste", Method: "GET"},
|
||||
{Name: "création", Method: "POST", Body: `{"action":"create","name":"Alpha"}`},
|
||||
{Name: "création, même nom", Method: "POST", Body: `{"action":"create","name":"Alpha"}`},
|
||||
{Name: "création translittérée", Method: "POST", Body: `{"action":" CREATE ","name":" Réseau NAS — ça ñ "}`},
|
||||
{Name: "création sans lettres", Method: "POST", Body: `{"action":"create","name":"!!!"}`},
|
||||
{Name: "création sans lettres bis", Method: "POST", Body: `{"action":"create","name":"???"}`},
|
||||
{Name: "création longue", Method: "POST",
|
||||
Body: `{"action":"create","name":"` + strings.Repeat("Ab-", 30) + `"}`},
|
||||
{Name: "création très longue", Method: "POST",
|
||||
Body: `{"action":"create","name":"` + strings.Repeat("ü", 70) + `"}`},
|
||||
{Name: "création vide", Method: "POST", Body: `{"action":"create","name":" "}`},
|
||||
{Name: "renommage", Method: "POST", Body: `{"action":"rename","slug":" alpha-2 ","name":" Bêta "}`},
|
||||
{Name: "renommage vide", Method: "POST", Body: `{"action":"rename","slug":"alpha-2","name":""}`},
|
||||
{Name: "renommage inconnu", Method: "POST", Body: `{"action":"rename","slug":"nope","name":"X"}`},
|
||||
{Name: "description", Method: "POST", Body: `{"action":"desc","slug":"alpha","desc":" Le projet Alpha. "}`},
|
||||
{Name: "description longue", Method: "POST", Body: `{"action":"desc","slug":"alpha-2","desc":"` + long + `"}`},
|
||||
{Name: "description coupée sur une espace", Method: "POST",
|
||||
Body: `{"action":"desc","slug":"projet","desc":"` + strings.Repeat("é", 1999) + ` suite"}`},
|
||||
{Name: "description effacée", Method: "POST", Body: `{"action":"desc","slug":"alpha-2","desc":" "}`},
|
||||
{Name: "description inconnue", Method: "POST", Body: `{"action":"desc","slug":"nope","desc":"x"}`},
|
||||
{Name: "bascule", Method: "POST", Body: `{"action":"switch","slug":"alpha"}`},
|
||||
{Name: "bascule sur l'actif", Method: "POST", Body: `{"action":"switch","slug":"alpha"}`},
|
||||
{Name: "bascule inconnue", Method: "POST", Body: `{"action":"switch","slug":"nope"}`},
|
||||
{Name: "bascule pendant une génération", Generating: true, Method: "POST", Body: `{"action":"switch","slug":"generale"}`},
|
||||
{Name: "suppression pendant une génération", Generating: true, Method: "POST", Body: `{"action":"delete","slug":"alpha"}`},
|
||||
{Name: "suppression inconnue pendant une génération", Generating: true, Method: "POST", Body: `{"action":"delete","slug":"nope"}`},
|
||||
{Name: "bascule inconnue pendant une génération", Generating: true, Method: "POST", Body: `{"action":"switch","slug":"nope"}`},
|
||||
{Name: "suppression de l'actif", Method: "POST", Body: `{"action":"delete","slug":"alpha"}`},
|
||||
{Name: "suppression inconnue", Method: "POST", Body: `{"action":"delete","slug":"nope"}`},
|
||||
{Name: "suppression", Method: "POST", Body: `{"action":"delete","slug":"projet"}`},
|
||||
{Name: "bascule vers un projet sans discussion", Method: "POST", Body: `{"action":"switch","slug":"projet-2"}`},
|
||||
{Name: "suppression bis", Method: "POST", Body: `{"action":"delete","slug":"alpha-2"}`},
|
||||
{Name: "suppression ter", Method: "POST", Body: `{"action":"delete","slug":"reseau-nas-ca-n"}`},
|
||||
{Name: "liste après", Method: "GET"},
|
||||
{Name: "action inconnue", Method: "POST", Body: `{"action":"Nuke","slug":"generale"}`},
|
||||
{Name: "action vide", Method: "POST", Body: `{}`},
|
||||
{Name: "corps vide", Method: "POST", Body: ``},
|
||||
{Name: "corps illisible", Method: "POST", Body: `{"action":"create"`},
|
||||
{Name: "type faux", Method: "POST", Body: `{"action":"create","name":7}`},
|
||||
{Name: "méthode refusée", Method: "PUT", Body: `{}`},
|
||||
} {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = st.Generating
|
||||
conv.mu.Unlock()
|
||||
rec := httptest.NewRecorder()
|
||||
handleProjects(rec, httptest.NewRequest(st.Method, "/api/projects", strings.NewReader(st.Body)))
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
st.Status = rec.Code
|
||||
var out any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("%s : %q", st.Name, rec.Body.String())
|
||||
}
|
||||
st.Out = parityZeroTimes(out)
|
||||
st.After = snapshot()
|
||||
steps = append(steps, st)
|
||||
}
|
||||
// Les dernières suppressions, jusqu'au dernier projet.
|
||||
for _, p := range listProjects() {
|
||||
if p.Slug == defaultProjectSlug {
|
||||
continue
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleProjects(rec, httptest.NewRequest("POST", "/api/projects",
|
||||
strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`)))
|
||||
var out any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
steps = append(steps, parityProjectStep{Name: "suppression de " + p.Slug, Method: "POST",
|
||||
Body: `{"action":"delete","slug":"` + p.Slug + `"}`, Status: rec.Code, Out: parityZeroTimes(out), After: snapshot()})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleProjects(rec, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"generale"}`)))
|
||||
var out any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
steps = append(steps, parityProjectStep{Name: "dernier projet", Method: "POST",
|
||||
Body: `{"action":"delete","slug":"generale"}`, Status: rec.Code, Out: parityZeroTimes(out), After: snapshot()})
|
||||
|
||||
got, err := parityJSON(map[string]any{"seed": seed, "seed_store": seedStore, "seed_mem": seedMem, "steps": steps})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = []byte(strings.ReplaceAll(string(got), home, "{{HOME}}"))
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityProjectsFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityProjectsFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityProjectsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("le hub des projets ne produit plus le journal rangé dans %s.\n"+
|
||||
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteProjets,\n"+
|
||||
"puis cargo test --test projects_api dit si le port Rust suit.\n--- obtenu ---\n%s", parityProjectsFile, got)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,10 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalCDPShutdown(w, r)
|
||||
case "/internal/v1/service/action":
|
||||
handleInternalServiceAction(w, r)
|
||||
case "/internal/v1/project/switch":
|
||||
handleInternalProjectSwitch(w, r)
|
||||
case "/internal/v1/project/drop-convs":
|
||||
handleInternalProjectDropConvs(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -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