Lot 13 : mémoire tenue par la façade (LOKI_MEM=rust)

La façade sert l'éditeur des pages mémoire (lecture, enregistrement,
renommage, suppression, déplacement vers un autre projet, index MEMORY.md
tenu par le code), le panneau des trackers, le mode mémoire du projet
actif et les interrupteurs du mode agent, quand LOKI_MEM=rust
(expérimental ; le Go reste le défaut).

Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre
(.bak, temporaire synchronisé puis renommé, relecture vérifiée), les
trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la
façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et
n'écrit jamais en clair quand la mémoire est verrouillée.

gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF,
erreurs du scanner et de type, structs anonymes) pour renvoyer au client
les mêmes messages que le Go ; il servira aux lots suivants.

Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent
par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go.

Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ;
parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de
route avec le disque et la base après chacun) → tests/memory.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 17:33:22 +00:00
1 parent db9b0b44f5
commit 1e5f0904f2
20 files changed
+11944 -18

No files matched your search

+2
View File
@@ -160,6 +160,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeFiles, "fichiers indisponibles : "
case facadePushRoutes[path]:
held, unavailable = facadePush, "notifications indisponibles : "
case facadeMemRoutes[path]:
held, unavailable = facadeMem, "mémoire indisponible : "
default:
return h
}
+34
View File
@@ -511,3 +511,37 @@ func TestFacadeNotifications(t *testing.T) {
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
}
}
func TestFacadeMemoire(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
// Sans la variable : le Go sert lui-même.
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String())
}
}
t.Setenv(facadeMemEnv, "rust")
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeMemRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Les routes voisines (coffre, projets) restent au Go.
for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s remis à la façade : %q", p, w.Body.String())
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package loki
import (
"os"
"strings"
)
// facade_mem.go — mémoire tenue par la façade Rust (LOKI_MEM=rust, lot 13 de
// la migration ; expérimental, le Go reste le défaut) : éditeur des pages,
// index MEMORY.md, déplacement d'une page vers un autre projet, panneau des
// trackers, mode mémoire et interrupteurs du mode agent.
//
// Les pages restent des fichiers memory/<projet>/*.md et les trackers des
// valeurs du bucket tracker : ce process continue de les lire et de les
// écrire pour sa propre boucle (outils mem_* et tracker, index injecté au
// modèle). Le chiffrement reste ici (la clé ne quitte pas ce process) : la
// façade fait sceller et ouvrir ses contenus par /internal/v1/crypt/*.
//
// Seules les routes changent de main ; celles qui arrivent ici par le tunnel
// du relais sont remises à la façade (withFacadeChat).
const facadeMemEnv = "LOKI_MEM"
// facadeMem : la façade tient-elle la mémoire ?
func facadeMem() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMemEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeMemRoutes : les routes que la façade sert alors (memory.rs).
var facadeMemRoutes = map[string]bool{
"/api/mem": true,
"/api/mem/save": true,
"/api/mem/delete": true,
"/api/mem/move": true,
"/api/agent": true,
"/api/tools": true,
"/api/skills": true,
"/api/agent/toggle": true,
"/api/tools/toggle": true,
"/api/skills/toggle": true,
"/api/agent/compact": true,
"/api/memory": true,
"/api/tracker": true,
}
+85
View File
@@ -0,0 +1,85 @@
package loki
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// parity_gojson_test.go — journal de référence du décodage JSON des requêtes
// (json.NewDecoder(r.Body).Decode(&req)) : champs décodés et message d'erreur,
// que plusieurs routes renvoient tels quels au client. Rangé dans
// rust/loki-rs/tests/parity_gojson/decode.json ; tests/gojson.rs le rejoue
// contre src/gojson.rs.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON
const parityGoJSONFile = "../../rust/loki-rs/tests/parity_gojson/decode.json"
type parityDecodeT struct {
Name string `json:"name"`
On bool `json:"on"`
N int `json:"n"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
Opt *string `json:"opt"`
Flag *bool `json:"flag"`
}
func TestPariteDecodageJSON(t *testing.T) {
type cas struct {
In string `json:"in"`
Out parityDecodeT `json:"out"`
Err string `json:"err,omitempty"`
}
var fx struct {
Cases []cas `json:"cases"`
}
bodies := []string{
``, ` `, "\n\t", `pas du json`, `{`, `{"name"`, `{"name":`, `{"name":"a"`, `{"name":"a",}`,
`{"name" "a"}`, `{"name":"a" "on":true}`, `{name:"a"}`, `{'name':"a"}`, `[`, `[1,]`, `[1 2]`,
`{"name":"a"} et la suite`, `{"name":"a"}{"name":"b"}`, `null`, `true`, `"texte"`, `12`, `[1,2]`,
`{"name":5}`, `{"name":true}`, `{"name":{}}`, `{"name":[]}`, `{"name":null}`, `{"NAME":"maj"}`,
`{"Name":"a","name":"b"}`, `{"on":"oui"}`, `{"on":1}`, `{"on":true,"name":3,"n":4}`,
`{"n":1.5}`, `{"n":"3"}`, `{"n":-7}`, `{"n":1e2}`, `{"args":["a","b"]}`, `{"args":["a",1,"c"]}`,
`{"args":"x"}`, `{"args":[]}`, `{"args":[null]}`, `{"env":{"A":"1","B":2}}`, `{"env":[]}`,
`{"opt":""}`, `{"opt":null}`, `{"opt":"x","flag":false}`, `{"flag":"non"}`,
`{"name":"été"}`, `{"name":"a\nb"}`, "{\"name\":\"a\x01b\"}", `{"name":"\x"}`,
`{"name":"\u12G4"}`, `tru`, `trux`, `nul`, `nulx`, `fals`, `falsy`, `-`, `-x`, `1.`, `1.x`, `1e`,
`1ex`, `{"a":1.}`, `{"a":01}`, `{"a":-}`, `[1,2,3}`, `{"a":1]`, "\x01", "\x7f", "\xc3\xa9",
`'x'`, `"x`, `{"a":"b"}}`, `}`, `]`, `,`, `:`, `{"a" : [ { "b" : null } ] , "name" : "ok" }`,
}
for _, b := range bodies {
var v parityDecodeT
c := cas{In: b}
if err := json.NewDecoder(strings.NewReader(b)).Decode(&v); err != nil {
c.Err = err.Error()
}
c.Out = v
fx.Cases = append(fx.Cases, c)
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityGoJSONFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityGoJSONFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityGoJSONFile)
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 décodage JSON ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON,\n"+
"puis cargo test --test gojson dit si le port Rust suit.\n--- obtenu ---\n%s", parityGoJSONFile, got)
}
}
+442
View File
@@ -0,0 +1,442 @@
package loki
import (
"encoding/json"
"io/fs"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"time"
)
// parity_mem_test.go — journal de référence de la mémoire (lot 13 de la
// migration Rust) : noms et titres de pages, slugs, modes mémoire, dates des
// trackers, puis une suite d'appels aux routes (éditeur des pages, index
// MEMORY.md, déplacement entre projets, mode agent, mode mémoire, trackers)
// avec, après chacun, le contenu du dossier memory/ et des clés touchées en
// base. Rangé dans rust/loki-rs/tests/parity_mem/mem.json ; tests/memory.rs le
// rejoue par la façade.
//
// Les dates sont lues dans un fuseau fixe (UTC+2) ; les identifiants de points
// (horodatages en ns) deviennent {{ID1}}, {{ID2}}… dans l'ordre d'apparition,
// et les instants « maintenant » {{NOW}}.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire
const parityMemFile = "../../rust/loki-rs/tests/parity_mem/mem.json"
type parityMemStep struct {
// Avant l'appel : fichiers sous memory/ et valeurs en base (« bucket/clé »)
// posés ; une valeur vide supprime.
Files map[string]string `json:"files,omitempty"`
Store map[string]string `json:"store,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
// Après l'appel.
Disk map[string]string `json:"disk"`
State any `json:"state"`
}
type parityMemWhen struct {
In string `json:"in"`
TS any `json:"ts,omitempty"`
DateOnly bool `json:"date_only,omitempty"`
Err string `json:"err,omitempty"`
}
type parityMemFixture struct {
Names []parityNameCase `json:"names"`
Titles map[string]string `json:"titles"`
Slugs map[string]string `json:"slugs"`
Modes map[string]string `json:"modes"`
When []parityMemWhen `json:"when"`
Seed map[string]string `json:"seed"`
Steps []parityMemStep `json:"steps"`
}
type parityNameCase struct {
In string `json:"in"`
Out string `json:"out,omitempty"`
Err string `json:"err,omitempty"`
}
var (
parityIDRe = regexp.MustCompile(`"id":"(\d{15,})"`)
parityNowRe = regexp.MustCompile(`"(ts|first_ts|last_ts)":(\d{12,14})`)
)
// parityMasker remplace identifiants et instants « maintenant » d'un JSON
// canonique (clés triées), dans l'ordre d'apparition.
type parityMasker struct {
ids map[string]string
}
func (m *parityMasker) mask(s string) string {
s = parityIDRe.ReplaceAllStringFunc(s, func(x string) string {
id := parityIDRe.FindStringSubmatch(x)[1]
ph, ok := m.ids[id]
if !ok {
ph = "{{ID" + strconv.Itoa(len(m.ids)+1) + "}}"
m.ids[id] = ph
}
return `"id":"` + ph + `"`
})
now := time.Now().UnixMilli()
return parityNowRe.ReplaceAllStringFunc(s, func(x string) string {
sm := parityNowRe.FindStringSubmatch(x)
v, _ := strconv.ParseInt(sm[2], 10, 64)
if d := v - now; d > -86400000 && d < 86400000 {
return `"` + sm[1] + `":"{{NOW}}"`
}
return x
})
}
// unmask : les {{IDn}} d'un corps de requête rendus à leur valeur.
func (m *parityMasker) unmask(s string) string {
for id, ph := range m.ids {
s = strings.ReplaceAll(s, ph, id)
}
return s
}
// canon : JSON aux clés triées, masqué, relu.
func (m *parityMasker) canon(t *testing.T, raw []byte) any {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("JSON illisible : %v (%s)", err, raw)
}
b, _ := json.Marshal(v)
var out any
if err := json.Unmarshal([]byte(m.mask(string(b))), &out); err != nil {
t.Fatal(err)
}
return out
}
func TestPariteMemoire(t *testing.T) {
home := testHome(t)
old := time.Local
time.Local = time.FixedZone("essai", 7200)
t.Cleanup(func() { time.Local = old })
var fx parityMemFixture
for _, in := range []string{"", " ", "notes", "notes.md", "NOTES.MD", "a b", "../x", "x/y", "é", "a.b-c_d",
".md", ".", "..", "x.md.md", " pad ", "tab\t", "MEMORY", "memory.md", "x.Md"} {
c := parityNameCase{In: in}
if out, err := memFileName(in); err != nil {
c.Err = err.Error()
} else {
c.Out = out
}
fx.Names = append(fx.Names, c)
}
fx.Titles = map[string]string{}
for _, in := range []string{"", "\n\n", "# Titre", "## Titre ##", " ### ", "\n \n# A\n# B", "sans dièse\nsuite",
"#\n#\nVrai titre", "\t# tab", "#  espace insécable ", "# # double"} {
fx.Titles[in] = titleOf(in)
}
fx.Slugs = map[string]string{}
for _, in := range []string{"Réseau NAS", " Hello World ", "àâäáã éèêë", "İstanbul", "Ωmega", strings.Repeat("ab", 30),
"---x---", "", "Ça_va?", "ÉCOLE", "Straße", strings.Repeat("a", 39) + "-bcd", "!!!", "Perso & Cie", "ÎLE ÔÔ ÙÛ Ñ Ç"} {
fx.Slugs[in] = slugifyIdent(in)
}
fx.Modes = map[string]string{}
for _, in := range []string{"", "off", " OFF ", "ondemand", "on-demand", "search", "always", "auto", "bizarre"} {
fx.Modes[in] = string(normalizeMemMode(in))
}
mk := &parityMasker{ids: map[string]string{}}
for _, in := range []string{"", "now", "NOW", " Maintenant ", "2024-03-10", "2024-03-10T08:15", "2024-03-10 7:05",
"2024-03-10 07:5", "2024-03-10 07:05:09", "2024-03-10 07:05:09.5", "2024-03-10 07:05:09,123456789999",
"2024-03-10 24:00", "2024-03-10 23:60", "2024-13-01", "2024-02-29", "2023-02-29", "2024-04-31", "2024-03",
"2024", "24", "2024-3", "2024-03-1", " 2024-03-10 ", "2024-03-10 07:05", "demain", "2024-03-10T07:05Z",
"today", "+024", "0000-01-01", "9999-12-31 23:59:59", "1234", "2024-03-10 07", "2024-03-10 7",
"x\x01y", "2024-03-10T", "2024-03-10 07:05:60"} {
c := parityMemWhen{In: in}
ts, dateOnly, err := parseWhen(in)
if err != nil {
c.Err = err.Error()
} else {
c.TS = ts
if d := ts - time.Now().UnixMilli(); d > -86400000 && d < 86400000 {
c.TS = "{{NOW}}"
}
c.DateOnly = dateOnly
}
fx.When = append(fx.When, c)
}
// Base de départ : deux projets, le premier actif, mode mémoire global.
fx.Seed = map[string]string{
"state/projects": `[{"slug":"generale","name":"Générale","created_at":1000},{"slug":"perso","name":"Perso & Cie","created_at":2000,"desc":"à moi"}]`,
"state/active_project": "generale",
"config/MEM_MODE": "ondemand",
}
apply := func(store map[string]string) {
keys := make([]string, 0, len(store))
for k := range store {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b, key, _ := strings.Cut(k, "/")
if b == bkState && key == stActiveProjectKey {
if store[k] == "" {
putStr(bkState, key, "")
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
} else if err := setActiveProject(store[k]); err != nil {
t.Fatal(err)
}
continue
}
if err := putStr(b, key, store[k]); err != nil {
t.Fatal(err)
}
}
}
apply(fx.Seed)
memRoot := filepath.Join(home, "memory")
disk := func() map[string]string {
out := map[string]string{}
_ = filepath.WalkDir(memRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
rel, _ := filepath.Rel(memRoot, p)
b, _ := os.ReadFile(p)
out[filepath.ToSlash(rel)] = string(b)
return nil
})
return out
}
state := func() any {
st := map[string]any{
"agent": getStr(bkState, "agent"),
"active": getStr(bkState, stActiveProjectKey),
"compact": ReadConfig()["COMPACT"],
"mem_mode": ReadConfig()["MEM_MODE"],
"projects": nil,
"trackers": map[string]any{},
"tracker_raw": map[string]string{},
}
if b := getBytes(bkState, stProjectsKey); len(b) > 0 {
var v any
if json.Unmarshal(b, &v) == nil {
st["projects"] = v
}
}
for k, v := range allKV(bkTracker) {
var x any
if json.Unmarshal([]byte(v), &x) == nil {
st["trackers"].(map[string]any)[k] = x
} else {
st["tracker_raw"].(map[string]string)[k] = v
}
}
b, _ := json.Marshal(st)
return mk.canon(t, b)
}
run := func(st parityMemStep) parityMemStep {
for rel, content := range st.Files {
p := filepath.Join(memRoot, filepath.FromSlash(rel))
if content == "" {
_ = os.RemoveAll(p)
continue
}
if strings.HasSuffix(rel, "/") {
_ = os.MkdirAll(p, 0o755)
continue
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
apply(st.Store)
w := httptest.NewRecorder()
r := httptest.NewRequest(st.Method, mk.unmask(st.Path), strings.NewReader(mk.unmask(st.Body)))
switch r.URL.Path {
case "/api/mem":
handleMem(w, r)
case "/api/mem/save":
handleMemSave(w, r)
case "/api/mem/delete":
handleMemDelete(w, r)
case "/api/mem/move":
handleMemMove(w, r)
case "/api/agent", "/api/tools", "/api/skills":
handleAgent(w, r)
case "/api/agent/toggle", "/api/tools/toggle", "/api/skills/toggle":
handleAgentToggle(w, r)
case "/api/agent/compact":
handleCompactToggle(w, r)
case "/api/memory":
handleMemoryMode(w, r)
case "/api/tracker":
handleTracker(w, r)
default:
t.Fatalf("route inattendue %s", st.Path)
}
st.Status = w.Code
st.Out = mk.canon(t, w.Body.Bytes())
st.Disk = disk()
st.State = state()
return st
}
for _, st := range []parityMemStep{
// --- pages ---
{Method: "GET", Path: "/api/mem"},
{Method: "GET", Path: "/api/mem?name=absente"},
{Method: "GET", Path: "/api/agent"},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses","content":"# Courses\n\nlait\n\n\n"}`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes.md","content":"Sans titre"}`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"../evil","content":"x"}`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":" ","content":"x"}`},
{Method: "POST", Path: "/api/mem/save", Body: `pas du json`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":5}`},
{Method: "POST", Path: "/api/mem/save", Body: `[1]`},
{Method: "POST", Path: "/api/mem/save", Body: ``},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses","content":"# Courses v2\nœufs & <pain>"}`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes","old":"notes","content":"## Titre\nmaj"}`},
{Method: "GET", Path: "/api/mem?name=courses-v2"},
{Method: "GET", Path: "/api/mem?name=+courses-v2.md+"},
{Method: "GET", Path: "/api/mem?name=a&name=courses-v2"},
{Method: "GET", Path: "/api/mem?name=../x"},
{Files: map[string]string{"generale/Zeta.MD": "# Zêta\n", "generale/.cache.md": "caché", "generale/dossier.md/": "-",
"generale/notes.txt": "pas une page", "generale/vide.md": "\n\n"},
Method: "GET", Path: "/api/tools"},
{Method: "POST", Path: "/api/mem/delete", Body: `{"name":"absente"}`},
{Method: "POST", Path: "/api/mem/delete", Body: `{"name":"bad/name"}`},
{Method: "POST", Path: "/api/mem/delete", Body: `{"name":"vide"}`},
{Method: "POST", Path: "/api/mem/delete", Body: `{nope}`},
// Accroche écrite à la main : un réenregistrement du même nom la perd
// (ligne retirée puis rajoutée à la fin), comme le Go.
{Files: map[string]string{"generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v2](courses-v2.md) — la liste\n- [Titre](notes.md)\n"},
Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses-v2","content":"# Courses v3"}`},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"neuve","content":"# Neuve"}`},
// --- déplacement ---
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"generale"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":" generale "}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"inconnu"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"MEMORY","to":"perso"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"x/y","to":"perso"}`},
{Method: "POST", Path: "/api/mem/move", Body: `[]`},
{Method: "GET", Path: "/api/mem/move"},
{Files: map[string]string{"generale/doublon.md": "# A", "perso/doublon.md": "# B"},
Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"perso"}`},
// Bascule vers le projet perso : ses pages, son nom.
{Store: map[string]string{"state/active_project": "perso"}, Method: "GET", Path: "/api/skills"},
{Method: "POST", Path: "/api/mem/save", Body: `{"name":"perso-1","content":"Première\nligne"}`},
{Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"generale"}`},
{Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/agent"},
// --- mode agent, compactage ---
{Method: "POST", Path: "/api/agent/toggle", Body: `{"on":true}`},
{Method: "GET", Path: "/api/tools"},
{Method: "POST", Path: "/api/skills/toggle", Body: `{"on":"oui"}`},
{Method: "POST", Path: "/api/tools/toggle", Body: `{"on":true}`},
{Method: "POST", Path: "/api/agent/toggle", Body: `pas du json`},
{Method: "POST", Path: "/api/agent/compact", Body: `{"on":false}`},
{Method: "GET", Path: "/api/agent"},
{Method: "POST", Path: "/api/agent/compact", Body: `{}`},
{Store: map[string]string{"config/COMPACT": " NON "}, Method: "GET", Path: "/api/agent"},
{Method: "POST", Path: "/api/agent/compact", Body: `{"on":true}`},
// --- mode mémoire ---
{Method: "GET", Path: "/api/memory"},
{Method: "POST", Path: "/api/memory", Body: `{"mode":" Search "}`},
{Method: "GET", Path: "/api/agent"},
{Method: "POST", Path: "/api/memory", Body: `pas du json`},
{Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`},
{Method: "PUT", Path: "/api/memory", Body: `{"mode":"ondemand"}`},
{Store: map[string]string{"state/active_project": "fantome"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`},
{Method: "GET", Path: "/api/memory"},
{Store: map[string]string{"state/active_project": "perso"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"ondemand"}`},
{Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/memory"},
// --- trackers ---
{Method: "GET", Path: "/api/tracker"},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-10","text":" 78,2 kg "}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-09 08:15","text":"78,9"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"poids","text":"77"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"ADD","name":"Réseau NAS","when":"2023","text":"installé <ok> & fini"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"","text":"x"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","text":" "}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","when":"demain","text":"y"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"!!!","when":"2025-01","text":"z"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-01","text":"m"}`},
{Method: "GET", Path: "/api/tracker?slug=poids"},
{Method: "GET", Path: "/api/tracker?slug=absent"},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","text":" 78,0 kg "}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","when":"2024-03-11 07:00"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID1}}","when":"hier","text":"perdu"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"999","text":"x"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"absent","id":"{{ID1}}","text":"x"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"reseau-nas","name":" Réseau NAS (bureau) "}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"poids","name":"POIDS"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"Poids"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":" "}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"???"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"absent","name":"Neuf"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"tracker","to":"perso"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"inconnu"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"generale"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"absent","to":"perso"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-02","text":"perso"}`,
Store: map[string]string{"state/active_project": "perso"}},
{Store: map[string]string{"state/active_project": "generale"},
Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"meme-jour","to":"perso"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"poids"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"absent"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"bidule"}`},
{Method: "POST", Path: "/api/tracker", Body: `pas du json`},
{Method: "POST", Path: "/api/tracker", Body: `"texte"`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":3}`},
{Method: "PUT", Path: "/api/tracker"},
{Store: map[string]string{"tracker/generale/casse": "pas du json"}, Method: "GET", Path: "/api/tracker"},
{Method: "GET", Path: "/api/tracker?slug=casse"},
{Store: map[string]string{"tracker/generale/vide": `{"name":"Vide","events":null}`}, Method: "GET", Path: "/api/tracker?slug=vide"},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"vide","name":"VIDE"}`},
{Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"vide","id":"1"}`},
{Method: "GET", Path: "/api/tracker"},
} {
fx.Steps = append(fx.Steps, run(st))
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityMemFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityMemFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityMemFile)
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("la mémoire ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire,\n"+
"puis cargo test --test memory dit si le port Rust suit.\n--- obtenu ---\n%s", parityMemFile, got)
}
}