mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
L'elevation ajoutee en 0.7.3 n'etait jamais atteinte. Elle n'etait cablee que dans `install` et dans l'installeur de mise a jour (double-clic sur l'exe telecharge) ; or les utilisateurs font `ajean update` ou cliquent le bouton de l'interface. restartAfterUpdate ne fait rien sous Windows, et au redemarrage appFirstRun sort avant d'arriver la (l'application se reconnait comme etant deja l'installee). Aucun poste ne migrait donc jamais. postUpdateMigrate est appele apres une mise a jour reussie, cote CLI comme cote UI web. Une hypothese a ete invalidee au passage, et elle changeait tout : on croyait qu'un binaire en cours d'execution empechait de renommer le dossier qui le contient. C'est faux — Windows verrouille le fichier .exe, pas le nom de ses dossiers parents. Verifie sur banc d'essai. La migration peut donc se faire depuis l'application en marche, ce qui rend ce chemin possible. Consequence non anticipee, traitee ici : le binaire installe vit DANS le dossier de donnees. Le renommer deplace donc le programme, et laisse un raccourci du menu Demarrer et une entree de PATH pointant dans le vide. relinkAfterMigration repointe le raccourci, ajoute le nouveau dossier bin au PATH et retire l'ancien. Course de donnees corrigee : retryHomeMigration reinitialisait le cache du dossier resolu sans verrou, alors qu'il est desormais appele depuis un handler HTTP pendant que le serveur sert d'autres requetes. sync.Once est remplace par un RWMutex, et les reinitialisations directes des installeurs Unix passent par resetResolvedHome.
275 lines
11 KiB
Go
275 lines
11 KiB
Go
package ajean
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Migration du dossier de données jean → ajean.
|
|
//
|
|
// C'est le seul endroit du renommage qui peut faire PERDRE des données : le
|
|
// dossier contient la config, les clés, la mémoire, les conversations, et
|
|
// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en
|
|
// découlent, et elles expliquent tout le code ci-dessous :
|
|
//
|
|
// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename
|
|
// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et
|
|
// il n'existe aucun instant où les données seraient à moitié quelque part.
|
|
// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme)
|
|
// garantit qu'on reste sur le même volume, donc que le rename est bien un
|
|
// rename et non un copy+delete déguisé.
|
|
//
|
|
// 2. Si le rename échoue — service en cours qui tient des handles sous Windows,
|
|
// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR
|
|
// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme
|
|
// avant ; une machine qui a perdu ses modèles, non. La migration est donc
|
|
// retentée à chaque démarrage jusqu'à ce qu'elle passe.
|
|
//
|
|
// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ».
|
|
|
|
var (
|
|
// homeMu protège la résolution du dossier. Un simple sync.Once ne suffisait
|
|
// pas : retryHomeMigration le RÉINITIALISE, et il est appelé depuis le
|
|
// handler HTTP de mise à jour pendant que le serveur sert d'autres requêtes.
|
|
// Réécrire ces variables sans verrou était une course de données.
|
|
homeMu sync.RWMutex
|
|
homeResolved bool
|
|
homePath string
|
|
|
|
// migrationDeferred retient pourquoi le dossier n'a pas pu être renommé,
|
|
// pour que seules les commandes qui parlent d'emplacements en fassent état.
|
|
migrationDeferred string
|
|
)
|
|
|
|
// homeMigrationNotice renvoie le message à montrer quand la migration attend
|
|
// encore, ou "" s'il n'y a rien à signaler. Renvoie aussi le conseil qui va
|
|
// avec : sous Windows, l'obstacle est presque toujours un manque de droits sur
|
|
// C:\ProgramData, et la réponse tient en une phrase.
|
|
func homeMigrationNotice() string {
|
|
if migrationDeferred == "" {
|
|
return ""
|
|
}
|
|
msg := migrationDeferred + "\n Tout fonctionne : AJEAN continue d'utiliser ce dossier."
|
|
if runtime.GOOS == "windows" {
|
|
return msg + "\n Pour aligner les noms, ferme AJEAN puis lance « ajean install » en administrateur."
|
|
}
|
|
return msg + "\n Pour aligner les noms : sudo ajean install."
|
|
}
|
|
|
|
// renameCause extrait la cause réelle d'un échec de rename. os.Rename renvoie un
|
|
// *os.LinkError dont le texte répète les deux chemins complets — dans un message
|
|
// qui les cite déjà, ça donne trois fois la même chose et rend l'essentiel
|
|
// (« Accès refusé ») illisible. On ne garde que ce dernier.
|
|
func renameCause(err error) string {
|
|
var le *os.LinkError
|
|
if errors.As(err, &le) && le.Err != nil {
|
|
return le.Err.Error()
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// migratedDefaultHome renvoie le dossier de données par défaut, en migrant
|
|
// l'ancien dossier « jean » vers « ajean » à la première résolution du process.
|
|
// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux
|
|
// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre.
|
|
//
|
|
// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un
|
|
// chemin : un choix explicite de l'utilisateur ne se migre pas.
|
|
func migratedDefaultHome() string {
|
|
homeMu.RLock()
|
|
if homeResolved {
|
|
p := homePath
|
|
homeMu.RUnlock()
|
|
return p
|
|
}
|
|
homeMu.RUnlock()
|
|
|
|
homeMu.Lock()
|
|
defer homeMu.Unlock()
|
|
if !homeResolved { // un autre appelant a pu résoudre entre les deux verrous
|
|
homePath, homeResolved = resolveDefaultHome(), true
|
|
}
|
|
return homePath
|
|
}
|
|
|
|
func resolveDefaultHome() string {
|
|
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
|
|
}
|
|
|
|
// retryHomeMigration relance la résolution — donc la migration — après avoir
|
|
// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé.
|
|
//
|
|
// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process
|
|
// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien
|
|
// que la migration tentée au démarrage échouerait indéfiniment et que la machine
|
|
// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est
|
|
// arrêté : c'est là qu'on retente.
|
|
//
|
|
// L'accès au cache est protégé par homeMu, mais cela ne rend pas l'appel anodin
|
|
// pour autant : les chemins DÉJÀ CALCULÉS par l'appelant deviennent obsolètes
|
|
// dès que la migration aboutit (voir migrateThenResolveTarget et
|
|
// relinkAfterMigration, qui reconstruisent ce qui en dépend).
|
|
func retryHomeMigration() bool {
|
|
before := migratedDefaultHome() // force la résolution si elle n'a pas eu lieu
|
|
|
|
homeMu.Lock()
|
|
homeResolved = false
|
|
homeMu.Unlock()
|
|
|
|
return migratedDefaultHome() != before
|
|
}
|
|
|
|
// migrateHome contient toute la logique de migration, isolée des chemins réels
|
|
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
|
|
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
|
|
// l'ancien si elle a échoué.
|
|
func migrateHome(target, legacy string) string {
|
|
if isDir(target) {
|
|
return target // déjà migré (ou installation neuve déjà faite)
|
|
}
|
|
if !isDir(legacy) {
|
|
return target // installation neuve : rien à migrer
|
|
}
|
|
|
|
// Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique.
|
|
sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target))
|
|
if isDir(sibling) {
|
|
return sibling
|
|
}
|
|
if err := os.Rename(legacy, sibling); err != nil {
|
|
// On reste sur l'ancien chemin — tout fonctionne — et on retentera plus
|
|
// tard. Deux causes courantes sous Windows : un service AJEAN qui tient
|
|
// encore un handle, ou, bien plus fréquent, un utilisateur non
|
|
// administrateur : renommer C:\ProgramData\jean exige d'écrire dans
|
|
// C:\ProgramData, ce qu'un compte standard ne peut pas faire.
|
|
//
|
|
// On ENREGISTRE la raison au lieu de l'afficher. L'afficher condamnait
|
|
// tout utilisateur non administrateur à voir un message d'erreur
|
|
// technique avant CHAQUE commande, y compris `ajean help`, pour une
|
|
// situation qui n'a aucune conséquence : son installation fonctionne.
|
|
// Le message n'a sa place que là où il est actionnable — `ajean where`
|
|
// et `ajean install`, voir homeMigrationNotice().
|
|
migrationDeferred = fmt.Sprintf("dossier de données encore en %s — renommage en %s refusé (%s).",
|
|
legacy, filepath.Base(sibling), renameCause(err))
|
|
return legacy
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling)
|
|
rewriteHomeReferences(legacy, sibling)
|
|
return sibling
|
|
}
|
|
|
|
// configFilesToRewrite liste les fichiers de configuration susceptibles de
|
|
// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint
|
|
// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle
|
|
// les 51 000 fichiers d'un dossier de données.
|
|
func configFilesToRewrite(home string) []string {
|
|
files := []string{
|
|
filepath.Join(home, "config.env"),
|
|
filepath.Join(home, "model_dirs.json"),
|
|
filepath.Join(home, "mcp.json"),
|
|
filepath.Join(home, "webprefs.json"),
|
|
}
|
|
// Les presets sont des config.env alternatives : ils portent le même BIN.
|
|
presets, _ := filepath.Glob(filepath.Join(home, "configs", "*"))
|
|
return append(files, presets...)
|
|
}
|
|
|
|
// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN
|
|
// dossier de données dans les fichiers de configuration.
|
|
//
|
|
// Sans ça, la migration casse l'installation qu'elle est censée préserver :
|
|
// `config.env` contient typiquement
|
|
//
|
|
// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe
|
|
//
|
|
// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le
|
|
// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus.
|
|
// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
|
|
//
|
|
// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit
|
|
// avoir tout réécrit alors qu'il reste des références mortes :
|
|
//
|
|
// C:\ProgramData\jean forme native, dans config.env
|
|
// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur
|
|
// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json,
|
|
// mcp.json, webprefs.json sont écrits par json.Marshal)
|
|
//
|
|
// Best-effort par fichier : un fichier illisible est sauté sans compromettre
|
|
// les autres.
|
|
func rewriteHomeReferences(oldHome, newHome string) {
|
|
variants := [][2]string{{oldHome, newHome}}
|
|
if slash := filepath.ToSlash(oldHome); slash != oldHome {
|
|
variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)})
|
|
// Forme JSON : chaque antislash est doublé.
|
|
variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)})
|
|
}
|
|
for _, path := range configFilesToRewrite(newHome) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out := string(b)
|
|
for _, v := range variants {
|
|
out = strings.ReplaceAll(out, v[0], v[1])
|
|
}
|
|
if out == string(b) {
|
|
continue
|
|
}
|
|
fi, err := os.Stat(path)
|
|
mode := os.FileMode(0o644)
|
|
if err == nil {
|
|
mode = fi.Mode()
|
|
}
|
|
if err := os.WriteFile(path, []byte(out), mode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err)
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path))
|
|
}
|
|
adoptLegacyStateFiles(newHome)
|
|
}
|
|
|
|
// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un
|
|
// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le
|
|
// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json
|
|
// et mcp.json gardent des chemins morts.
|
|
//
|
|
// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une
|
|
// version antérieure utilisait des littéraux bruts et remplaçait en fait un
|
|
// antislash par lui-même, ce qui laissait passer le remplacement natif et
|
|
// produisait un JSON invalide (« invalid character 'U' in string escape code »),
|
|
// donc une liste de dossiers de modèles perdue. Couvert par un test.
|
|
func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") }
|
|
|
|
// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service
|
|
// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le
|
|
// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et
|
|
// à en démarrer un second.
|
|
func adoptLegacyStateFiles(home string) {
|
|
for _, ext := range []string{".pid", ".log"} {
|
|
from := filepath.Join(home, legacyServiceName()+ext)
|
|
to := filepath.Join(home, "ajean"+ext)
|
|
if _, err := os.Stat(to); err == nil {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(from); err != nil {
|
|
continue
|
|
}
|
|
_ = os.Rename(from, to)
|
|
}
|
|
}
|
|
|
|
// resetResolvedHome oublie le dossier résolu, pour que le prochain appel reparte
|
|
// du disque. Utilisé par `install` juste après avoir migré l'agencement : sans
|
|
// ça, la suite de l'installation continuerait de viser l'ancien emplacement.
|
|
func resetResolvedHome() {
|
|
homeMu.Lock()
|
|
homeResolved = false
|
|
homeMu.Unlock()
|
|
}
|