Migration 0.7 automatique dans 'ajean install'

Un seul fichier, migrate_07.go, sans dependance entrante : le supprimer et
retirer les deux appels suffira le jour venu. Declenchee UNIQUEMENT par install
(root, delibere) et non au demarrage, contrairement a ce qui existait en 0.7 et
qui pouvait laisser une machine distante sans service.

Deplace configs/->presets/, MEMORY/->memory/, les .gguf->models/, reprend l'etat
en base, range les anciens fichiers dans avant-0.8/ et desactive les unites jean*
(sans quoi un second llama-server demarrait au boot suivant).

Trois tests : le cas nominal, l'installation neuve, et la machine deja migree a
la main. Ils ont trouve deux bugs reels de casse de fichiers (MEMORY/memory se
confondent sur Windows et macOS).
This commit is contained in:
nathaninline committed 2026-08-07 19:16:55 +02:00
1 parent 35edc631a9
commit 664b13753b
7 files changed
+452 -210

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
package ajean
// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux.
//
// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et
// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce
// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et
// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier —
// la 0.8 ne contient AUCUN autre code de compatibilité.
//
// POURQUOI ICI, ET PAS AU DÉMARRAGE. La version précédente migrait à chaque
// lancement. Sur un parc, ça veut dire une réécriture d'unités systemd à chaque
// boot, et un échec laisse une machine distante sans service et sans terminal
// pour réparer. `install` est le seul bon moment : root, délibéré, et il écrit
// déjà les unités.
//
// CE QU'ON NE DÉTRUIT PAS. Les presets, la mémoire et les modèles sont
// DÉPLACÉS, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis
// rangés dans avant-0.8/ — ils ne servent plus, mais ils sont là si quelque
// chose a mal tourné.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// legacyUnits : les noms de services qu'a portés le produit avant la 0.8.
//
// Les désactiver n'est pas cosmétique. Une unité `jean.service` restée active
// relance un SECOND llama-server au prochain boot : deux moteurs sur le port
// 8080, la VRAM prise deux fois, et un démarrage qui échoue à moitié sans
// raison apparente.
var legacyUnits = []string{"jean", "jean-link", "ajean", "ajean-link"}
// hasEntry dit si home contient une entrée portant EXACTEMENT ce nom.
//
// os.Stat ne convient pas : sous Windows et macOS le système de fichiers est
// insensible à la casse, donc Stat("MEMORY") réussit sur un dossier nommé
// « memory ». Une installation 0.8 toute neuve passait ainsi pour une 0.7, et
// la migration se redéclenchait à chaque `install`. On compare donc les noms
// réels, tels que le système de fichiers les rapporte.
func hasEntry(home, name string) bool {
entries, err := os.ReadDir(home)
if err != nil {
return false
}
for _, e := range entries {
if e.Name() == name {
return true
}
}
return false
}
// sameDir dit si deux chemins désignent le MÊME dossier — ce qui arrive quand
// ils ne diffèrent que par la casse sur un système de fichiers qui l'ignore.
func sameDir(a, b string) bool {
ai, err := os.Stat(a)
if err != nil {
return false
}
bi, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(ai, bi)
}
// needsMigration07 dit si le dossier de données est encore dans l'ancienne
// disposition. On se fie à ce qui ne peut PAS exister en 0.8.
func needsMigration07(home string) bool {
return hasEntry(home, "config.env") || hasEntry(home, "configs") || hasEntry(home, "MEMORY")
}
// migrateFrom07 reprend une installation 0.7.x. Sans effet si le dossier est
// déjà en 0.8. Doit être appelée AVANT provisionDataDir : elle déplace des
// dossiers entiers, ce qui suppose que la destination n'existe pas encore.
func migrateFrom07(home string) error {
if !needsMigration07(home) {
return nil
}
fmt.Printf("\n%s installation 0.7 détectée — reprise en cours\n", cyan("[migration]"))
// 1. Arrêter les anciens services : ils tiennent les fichiers qu'on déplace,
// et sous Windows un .gguf ouvert par llama-server refuse d'être renommé.
stopLegacyUnits()
// 2. Les dossiers. Un rename sur le même volume est instantané, même pour
// 130 Go de modèles, et atomique : jamais d'état à moitié déplacé.
for _, m := range []struct{ from, to string }{
{"configs", "presets"},
{"MEMORY", "memory"},
} {
if !hasEntry(home, m.from) { // nom exact : « MEMORY » n'est pas « memory »
continue
}
src, dst := filepath.Join(home, m.from), filepath.Join(home, m.to)
// « configs » et « presets » ne se confondent pas, mais « MEMORY » et
// « memory » si : sur un système insensible à la casse, ouvrir la
// destination ouvre en réalité la source. On ne considère donc la
// destination comme distincte que si c'est un AUTRE dossier.
if !sameDir(src, dst) {
// Destination déjà remplie : la machine a été migrée à la main et
// l'ancien dossier n'a pas été retiré. Écraser détruirait le travail
// fait, fusionner devinerait qui gagne. On laisse les deux et on le
// dit — c'est le seul cas où quelqu'un doit trancher.
if n, _ := os.ReadDir(dst); len(n) > 0 {
fmt.Printf(" %s %s/ ET %s/ existent tous les deux — %s/ laissé tel quel, à supprimer à la main\n",
yellow("[info]"), m.from, m.to, m.from)
continue
}
_ = os.Remove(dst) // destination vide créée par une install précédente
}
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("%s → %s : %w", m.from, m.to, err)
}
fmt.Printf(" %s %s/ → %s/\n", green("✓"), m.from, m.to)
}
if n, err := moveModels(home); err != nil {
return err
} else if n > 0 {
fmt.Printf(" %s %d modèle(s) → models/\n", green("✓"), n)
}
// 3. Les réglages, vers la base.
if err := importLegacyState(home); err != nil {
return err
}
fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]"))
return nil
}
// stopLegacyUnits est une variable pour que le test de migration n'aille pas
// invoquer systemctl sur la machine qui exécute les tests.
var stopLegacyUnits = stopLegacyUnitsReal
// stopLegacyUnitsReal arrête et désactive les services d'avant la 0.8,
// silencieux sur ceux qui n'existent pas (le cas courant).
func stopLegacyUnitsReal() {
for _, u := range legacyUnits {
switch runtime.GOOS {
case "linux":
_ = exec.Command("systemctl", "stop", u).Run()
_ = exec.Command("systemctl", "disable", u).Run()
case "darwin":
for _, label := range []string{"com.jean." + u, "com.ajean." + u} {
_ = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/"+label+".plist").Run()
}
}
}
if runtime.GOOS == "linux" {
_ = exec.Command("systemctl", "daemon-reload").Run()
}
}
// moveModels range les .gguf de la racine dans models/. Renvoie le nombre
// déplacé. Un fichier qui résiste (encore ouvert) interrompt la migration : le
// laisser derrière donnerait une installation à laquelle il manque un modèle,
// sans le dire.
func moveModels(home string) (int, error) {
entries, err := os.ReadDir(home)
if err != nil {
return 0, err
}
dst := filepath.Join(home, "models")
n := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
continue
}
if n == 0 {
if err := os.MkdirAll(dst, 0o755); err != nil {
return 0, err
}
}
if err := os.Rename(filepath.Join(home, name), filepath.Join(dst, name)); err != nil {
return n, fmt.Errorf("déplacement de %s : %w", name, err)
}
n++
}
return n, nil
}
// importLegacyState recopie les anciens fichiers d'état dans la base, puis les
// range dans avant-0.8/. Les clés et les buckets sont ceux de store.go.
func importLegacyState(home string) error {
old := filepath.Join(home, "avant-0.8")
if err := os.MkdirAll(old, 0o755); err != nil {
return err
}
consumed := []string{}
read := func(name string) string {
b, err := os.ReadFile(filepath.Join(home, name))
if err != nil {
return ""
}
consumed = append(consumed, name)
return string(b)
}
// La configuration d'abord : sans elle le moteur ne démarre pas.
if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
if err := WriteConfig(cfg); err != nil {
return err
}
fmt.Printf(" %s configuration (%d clés)\n", green("✓"), len(cfg))
}
if prefs := map[string]string{}; json.Unmarshal([]byte(read("webprefs.json")), &prefs) == nil && len(prefs) > 0 {
_ = replaceKV(bkPrefs, prefs)
}
if conv := read("conversation.json"); conv != "" {
_ = putBytes(bkChat, "conversation", []byte(conv))
fmt.Printf(" %s conversation\n", green("✓"))
}
// Clés et jetons : le contenu du fichier, débarrassé de sa fin de ligne.
for key, name := range map[string]string{
"api_key": ".api_key",
"web_key": ".web_key",
"crawl_key": ".crawl4ai_key",
"link_token": ".link_token",
"link_machine": ".link_machine",
} {
if v := strings.TrimSpace(read(name)); v != "" {
_ = putStr(bkState, key, v)
}
}
// Drapeaux : seule l'EXISTENCE du fichier comptait.
for key, name := range map[string]string{
"agent": ".agent_enabled",
"internet": ".internet_enabled",
"oai_public": ".oai_public",
} {
if _, err := os.Stat(filepath.Join(home, name)); err == nil {
_ = putBool(bkState, key, true)
consumed = append(consumed, name)
}
}
// Documents JSON repris tels quels.
for key, name := range map[string]string{
"model_dirs": "model_dirs.json",
"last_bench": ".last_bench.json",
"bench_presets": ".bench_presets.json",
"sysprompt": "sysprompt.txt",
} {
if v := read(name); v != "" {
if key == "sysprompt" {
_ = putStr(bkState, key, strings.TrimSpace(v))
} else {
_ = putBytes(bkState, key, []byte(v))
}
}
}
// mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît.
if v := read("mcp.json"); v != "" {
var f struct {
MCPServers json.RawMessage `json:"mcpServers"`
}
if json.Unmarshal([]byte(v), &f) == nil && len(f.MCPServers) > 0 {
_ = putBytes(bkState, "mcp", f.MCPServers)
}
}
for _, name := range consumed {
_ = os.Rename(filepath.Join(home, name), filepath.Join(old, name))
}
return nil
}
+150
View File
@@ -0,0 +1,150 @@
package ajean
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Test de la reprise 0.7 → 0.8. À supprimer avec migrate_07.go.
//
// Ce code déplace les presets, la mémoire et des modèles de plusieurs dizaines
// de gigaoctets sur la machine de quelqu'un : il ne peut pas ne pas être testé.
// fake07Home fabrique un dossier de données dans l'ancienne disposition.
func fake07Home(t *testing.T) string {
t.Helper()
home := testHome(t)
stopLegacyUnits = func() {} // pas de systemctl pendant les tests
t.Cleanup(func() { stopLegacyUnits = stopLegacyUnitsReal })
write := func(rel, body string) {
p := filepath.Join(home, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("config.env", "MODEL=\"m.gguf\"\nCTX=4096\nBIN=/usr/bin/llama-server\n")
write("configs/MON PRESET.env", "# NAME=MON PRESET\nMODEL=\"m.gguf\"\nCTX=8192\n")
write("MEMORY/user-preferences.md", "# Préférences\nfuseau: Europe/Paris\n")
write("m.gguf", "gguf")
write(".api_key", "sk-jean-secret\n")
write(".link_token", "jl_abcdef123456\n")
write(".agent_enabled", "")
write("webprefs.json", `{"theme":"dark"}`)
write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`)
write("sysprompt.txt", "Tu es AJEAN.\n")
write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`)
return home
}
func TestMigration07(t *testing.T) {
home := fake07Home(t)
if !needsMigration07(home) {
t.Fatal("ancienne disposition non détectée")
}
if err := migrateFrom07(home); err != nil {
t.Fatal(err)
}
// Les dossiers ont bougé, pas disparu.
for _, c := range []struct{ path, want string }{
{filepath.Join(home, "presets", "MON PRESET.env"), "CTX=8192"},
{filepath.Join(home, "memory", "user-preferences.md"), "Europe/Paris"},
{filepath.Join(home, "models", "m.gguf"), "gguf"},
} {
b, err := os.ReadFile(c.path)
if err != nil {
t.Fatalf("%s absent : %v", c.path, err)
}
if !strings.Contains(string(b), c.want) {
t.Errorf("%s : contenu inattendu %q", c.path, string(b))
}
}
// hasEntry et non os.Stat : sur un système de fichiers insensible à la casse,
// Stat("MEMORY") réussit sur le dossier « memory » fraîchement créé.
for _, gone := range []string{"configs", "MEMORY", "m.gguf", "config.env"} {
if hasEntry(home, gone) {
t.Errorf("%s traîne encore à la racine", gone)
}
}
// Les réglages sont en base.
cfg := ReadConfig()
if cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
t.Errorf("configuration non reprise : %v", cfg)
}
if readAPIKey() != "sk-jean-secret" {
t.Errorf("clé API = %q", readAPIKey())
}
if readLinkToken() != "jl_abcdef123456" {
t.Errorf("jeton de liaison = %q", readLinkToken())
}
if !agentEnabled() {
t.Error("mode agent perdu")
}
if readSysPrompt() != "Tu es AJEAN." {
t.Errorf("prompt système = %q", readSysPrompt())
}
if loadWebPrefs()["theme"] != "dark" {
t.Error("préférences perdues")
}
if servers, _ := LoadMCPConfig(); servers["fs"].Command != "npx" {
t.Errorf("serveurs MCP perdus : %v", servers)
}
LoadConversation()
if conv.Seq != 42 {
t.Errorf("conversation perdue (seq %d)", conv.Seq)
}
// Les fichiers consommés sont rangés, pas détruits.
if _, err := os.Stat(filepath.Join(home, "avant-0.8", "config.env")); err != nil {
t.Error("config.env n'a pas été conservée dans avant-0.8/")
}
// Idempotence : relancer ne doit plus rien voir à migrer.
if needsMigration07(home) {
t.Error("la migration se redéclencherait au prochain install")
}
if err := migrateFrom07(home); err != nil {
t.Fatalf("second appel : %v", err)
}
}
// Une installation 0.8 neuve ne doit rien déclencher.
func TestMigration07IgnoreUneInstallationNeuve(t *testing.T) {
home := testHome(t)
if err := provisionDataDir(); err != nil {
t.Fatal(err)
}
if needsMigration07(home) {
t.Fatal("migration déclenchée sur une installation neuve")
}
}
// Machine migrée à la main : les nouveaux dossiers sont remplis ET les anciens
// traînent encore. L'install ne doit rien écraser, ni échouer.
func TestMigration07NEcrasePasUnDossierDejaRempli(t *testing.T) {
home := fake07Home(t)
precieux := filepath.Join(home, "presets", "DEJA LA.env")
if err := os.MkdirAll(filepath.Dir(precieux), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(precieux, []byte("CTX=1\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := migrateFrom07(home); err != nil {
t.Fatalf("la migration doit se poursuivre, pas échouer : %v", err)
}
b, err := os.ReadFile(precieux)
if err != nil || !strings.Contains(string(b), "CTX=1") {
t.Error("le preset déjà migré a été écrasé")
}
if !hasEntry(home, "configs") {
t.Error("l'ancien dossier a été supprimé alors qu'il fallait le laisser")
}
}
+7
View File
@@ -47,6 +47,13 @@ func cmdInstall(args []string) error {
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName)
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
// 1. Arborescence + configuration de départ.
if err := provisionDataDir(); err != nil {
return err
+7
View File
@@ -23,6 +23,13 @@ func cmdInstall(args []string) error {
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" service = %s\n", serviceName())
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
// 1. Arborescence + configuration de départ (partagé avec le premier
// lancement de l'app, voir sys_datadir.go).
if err := provisionDataDir(); err != nil {