Files
Loki/internal/ajean/store.go
T
nathaninline 62ad4155a2 CLI elaguee et tests remis sur la base
- suppression des alias de commandes herites (skills, machine, tools, mem, upgrade...)
- aide reecrite et regroupee
- helper de test testHome + fermeture de la base (verrou bbolt sous Windows)
- tests des dossiers de modeles alignes sur models/
2026-08-07 17:03:27 +02:00

211 lines
6.0 KiB
Go

package ajean
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
// store.go — l'unique endroit où AJEAN écrit son état.
//
// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
// Une douzaine de formats, une douzaine de façons de rater une écriture
// concurrente, et un dossier de données illisible. Tout ça tient désormais dans
// une seule base bbolt — pur Go, un seul fichier, transactionnelle.
//
// Ce qui N'EST PAS en base, et pourquoi : les presets (presets/*.env) et les
// pages de mémoire (memory/*.md) restent des fichiers, parce qu'ils sont faits
// pour être lus, édités et sauvegardés à la main. Les modèles (.gguf) et les
// backends compilés restent des fichiers, évidemment.
// Buckets. Un par nature de donnée : ça garde les itérations bornées et rend le
// contenu de la base lisible au débogage.
const (
bkConfig = "config" // configuration de llama-server (ex-config.env)
bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée
)
// Les connexions sont mises en cache PAR CHEMIN, et non dans un singleton.
// $AJEAN_HOME peut désigner un autre dossier d'un appel à l'autre — c'est le cas
// dans les tests, qui donnent à chacun son dossier temporaire. Un singleton
// figerait la toute première base et tous les appels suivants écriraient dans le
// mauvais fichier, sans erreur et sans bruit.
var (
dbMu sync.Mutex
dbConns = map[string]*bolt.DB{}
)
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
// db ouvre la base (ou renvoie celle déjà ouverte pour ce chemin) et la garde
// ouverte pour la durée du process. bbolt prend un verrou exclusif sur le
// fichier : le délai d'attente évite qu'un second process (l'app lancée deux
// fois) se bloque indéfiniment.
func db() (*bolt.DB, error) {
path := dbPath()
dbMu.Lock()
defer dbMu.Unlock()
if d, ok := dbConns[path]; ok {
return d, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 3 * time.Second})
if err != nil {
return nil, fmt.Errorf("base %s inaccessible : %w", path, err)
}
err = d.Update(func(tx *bolt.Tx) error {
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
return err
}
}
return nil
})
if err != nil {
d.Close()
return nil, err
}
dbConns[path] = d
return d, nil
}
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
// face à une erreur d'E/S, et tous ont déjà un défaut.
// closeDB ferme la base ouverte pour le dossier courant, s'il y en a une. bbolt
// garde un verrou exclusif sur son fichier tant qu'il est ouvert : sans ça,
// impossible de supprimer ou déplacer le dossier de données sous Windows.
func closeDB() {
path := dbPath()
dbMu.Lock()
defer dbMu.Unlock()
if d, ok := dbConns[path]; ok {
_ = d.Close()
delete(dbConns, path)
}
}
func getBytes(bucket, key string) []byte {
d, err := db()
if err != nil {
return nil
}
var out []byte
_ = d.View(func(tx *bolt.Tx) error {
if v := tx.Bucket([]byte(bucket)).Get([]byte(key)); v != nil {
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
}
return nil
})
return out
}
// putBytes écrit une valeur. Une valeur nil supprime la clé.
func putBytes(bucket, key string, val []byte) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucket))
if val == nil {
return b.Delete([]byte(key))
}
return b.Put([]byte(key), val)
})
}
func getStr(bucket, key string) string { return string(getBytes(bucket, key)) }
// putStr écrit une chaîne ; une chaîne vide supprime la clé, pour que « absent »
// et « vide » ne soient jamais deux états distincts à distinguer.
func putStr(bucket, key, val string) error {
if val == "" {
return putBytes(bucket, key, nil)
}
return putBytes(bucket, key, []byte(val))
}
func getBool(bucket, key string) bool { return getStr(bucket, key) == "1" }
func putBool(bucket, key string, on bool) error {
if !on {
return putBytes(bucket, key, nil)
}
return putStr(bucket, key, "1")
}
// getJSON décode une valeur JSON dans dst. Renvoie false si la clé est absente
// ou illisible — dans les deux cas l'appelant garde son zéro.
func getJSON(bucket, key string, dst any) bool {
b := getBytes(bucket, key)
if len(b) == 0 {
return false
}
return json.Unmarshal(b, dst) == nil
}
func putJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putBytes(bucket, key, b)
}
// allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
func allKV(bucket string) map[string]string {
m := map[string]string{}
d, err := db()
if err != nil {
return m
}
_ = d.View(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bucket)).ForEach(func(k, v []byte) error {
m[string(k)] = string(v)
return nil
})
})
return m
}
// replaceKV remplace tout le contenu d'un bucket en une seule transaction.
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
// doit être un mélange de l'ancienne et de la nouvelle.
func replaceKV(bucket string, m map[string]string) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte(bucket)); err != nil {
return err
}
b, err := tx.CreateBucket([]byte(bucket))
if err != nil {
return err
}
for k, v := range m {
if v == "" {
continue
}
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
})
}