Files
Loki/internal/loki/web_llamacpp.go
T
Claude b378311873 Moteur : mettre à jour llama.cpp sans reconstruire l'image
llama.cpp publie plusieurs versions par jour ; l'image de Loki ne se
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à
la date du dernier build, et le rattraper imposait un rebuild complet de
2,6 Go pour un composant qui en pèse 170 Mo. Le panneau « Moteur » ne le
disait même pas : il annonçait « rien à mettre à jour ici ».

Réglages → Moteur affiche maintenant la version qui tourne (bXXXXX et son
commit, lus dans la bannière du binaire — jusqu'ici invisibles ailleurs
que dans le journal du moteur) et la met à jour en un clic.

Où le moteur est pris. Pas dans les releases GitHub de llama.cpp : elles
ne contiennent AUCUN binaire CUDA pour Linux, et le mode « précompilé »
retomberait sur Vulkan, donc sur une régression pour une carte NVIDIA. La
seule distribution CUDA/Linux officielle et précompilée est l'image de
conteneur — celle-là même dont l'image de Loki hérite. On lit son
manifeste OCI et on ne télécharge que les couches qui portent /app, en
descendant du sommet : le runtime CUDA (2 Go) et la base système sont
déjà là. S'arrêter au binaire ne suffit pas — llama.cpp le range dans une
couche et ses .so dans la précédente — d'où une règle d'arrêt sur
« binaire + libggml-base + libllama », et un garde-fou de taille qui
interdit de descendre jusqu'au runtime.

Le moteur atterrit dans /data/engine/<version>/, donc sur le volume de
données : il survit à un docker compose pull. La variante (CUDA, Vulkan,
SYCL, MUSA, CPU) est déduite des backends ggml posés à côté du moteur
courant — le conteneur ne sait pas de quelle image il vient, et faire
retenir « server-cuda » à l'utilisateur serait un piège.

Le risque, et ce qui le couvre. La mise à jour apporte llama.cpp, pas le
runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
CUDA plus récent ne chargerait pas son backend GPU. Le symptôme serait
silencieux — tout marche, mais sur le processeur. Le nouveau moteur est
donc lancé à blanc avant toute bascule ; il est refusé s'il ne démarre
pas, ET s'il ne voit plus aucune carte alors que le moteur courant en
voyait. Dans les deux cas le moteur courant n'est pas touché, et celui de
l'image reste intact : « revenir au moteur de l'image » y ramène en un
clic, sans réseau.

Vérifié de bout en bout contre le vrai ghcr.io (166 Mo, 7 s, toutes les
bibliothèques et leurs liens de version présents) et, pour les chemins
d'échec, contre un faux registre.

LOKI_OCI_REGISTRY permet de viser un miroir quand ghcr.io n'est pas
joignable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 20:43:47 +00:00

673 lines
21 KiB
Go

// web_llamacpp.go — pilotage du backend llama.cpp depuis l'UI web :
// statut (commit, retard, binaire, plan de build), vérification des mises à
// jour (git fetch), et jobs asynchrones install / update / rebuild dont la
// progression et les logs sont pollés par le client.
//
// GET /api/llamacpp → statut complet (dépôt, commit, binaire, plan)
// POST /api/llamacpp/check → git fetch + retard sur origin (réseau)
// POST /api/llamacpp/install → job d'installation {force?}
// POST /api/llamacpp/update → job de mise à jour {clean?}
// GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur)
package loki
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// lcJob est LE job llama.cpp en cours (un seul à la fois). Les lignes de log
// sont indexées de façon absolue (base = index de Lines[0]) pour que le client
// puisse poller « à partir de N » même quand on tronque le début.
type lcJob struct {
Action string // "install" | "update"
Phase string
Compiled int // fichiers compilés (progression du build)
Running bool
Err string
StartedAt int64
EndedAt int64
OldCommit string
NewCommit string
lines []string
base int
}
var (
lcMu sync.Mutex
lcCur *lcJob
)
const lcMaxLines = 4000
// lcAppend ajoute une ligne au log du job courant et met à jour la phase / le
// compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le
// spinner du CLI). Appelée par le sink de build (emitBuildLine).
func lcAppend(line string) {
lcMu.Lock()
defer lcMu.Unlock()
if lcCur == nil {
return
}
lcCur.lines = append(lcCur.lines, line)
if len(lcCur.lines) > lcMaxLines {
drop := len(lcCur.lines) - lcMaxLines
lcCur.lines = append([]string(nil), lcCur.lines[drop:]...)
lcCur.base += drop
}
if f := compiledFile(line); f != "" {
lcCur.Compiled++
lcCur.Phase = fmt.Sprintf("compilation… %d fichiers", lcCur.Compiled)
} else if p := phaseLabel(line); p != "" {
lcCur.Phase = p
}
lcSaveLine(line)
lcSave(false)
}
// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…)
// et la trace aussi dans le log.
func lcPhase(phase string) {
lcMu.Lock()
if lcCur != nil {
lcCur.Phase = phase
lcCur.lines = append(lcCur.lines, "▶ "+phase)
lcSaveLine("▶ " + phase)
lcSave(true)
}
lcMu.Unlock()
}
// startLcJob démarre un job (install ou update) si aucun n'est en cours.
func startLcJob(action string, run func()) error {
lcMu.Lock()
defer lcMu.Unlock()
if lcCur != nil && lcCur.Running {
return fmt.Errorf("un job %s est déjà en cours", lcCur.Action)
}
lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()}
lcResetLog()
lcSave(true)
setBuildSink(lcAppend)
go func() {
defer func() {
setBuildSink(nil)
lcMu.Lock()
lcCur.Running = false
lcCur.EndedAt = time.Now().Unix()
lcSave(true)
lcMu.Unlock()
}()
run()
}()
return nil
}
func lcFail(err error) {
lcMu.Lock()
if lcCur != nil {
lcCur.Err = err.Error()
lcCur.Phase = "échec"
lcCur.lines = append(lcCur.lines, "✗ "+err.Error())
lcSaveLine("✗ " + err.Error())
lcSave(true)
}
lcMu.Unlock()
}
func lcDone(msg string) {
lcMu.Lock()
if lcCur != nil {
lcCur.Phase = msg
lcCur.lines = append(lcCur.lines, "✓ "+msg)
lcSaveLine("✓ " + msg)
lcSave(true)
}
lcMu.Unlock()
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// handleLlamacpp renvoie le statut complet du backend llama.cpp : dépôt,
// commit courant, retard connu (sans fetch réseau), binaire compilé et son
// usage dans config.env, plan de build détecté, et le job éventuel.
func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
repo := llamacppRepoDir()
out := map[string]any{
"repo": repo,
"installed": isDir(filepath.Join(repo, ".git")),
}
if out["installed"].(bool) {
out["branch"] = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD")
out["commit"] = gitOutput(repo, "rev-parse", "--short", "HEAD")
out["commit_date"] = gitOutput(repo, "log", "-1", "--format=%ci")
out["commit_msg"] = gitOutput(repo, "log", "-1", "--format=%s")
if br, _ := out["branch"].(string); br != "" && br != "HEAD" {
if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+br); behind != "" {
n, _ := strconv.Atoi(behind)
out["behind"] = n
}
}
}
bin := llamaServerBin(repo)
out["bin"] = bin
cfgBin := ReadConfig()["BIN"]
out["config_bin"] = cfgBin
out["in_use"] = bin != "" && samePath(bin, cfgBin)
// Moteur livré par l'image Docker : il n'y a rien à installer, et l'image
// ne contient ni cmake ni compilateur. L'UI masque alors les trois modes,
// et le sélecteur par modèle propose « Image » au lieu de « Précompilé /
// Compilé », qui ne désignent rien ici.
out["provided"] = engineProvided()
out["provided_bin"] = providedEngineBin()
// Moteur téléchargé depuis l'image officielle llama.cpp (web_engine.go) : ni
// « précompilé », ni « compilé », ni « personnalisé ». Sans cette information,
// le bandeau d'état le rangeait dans « personnalisé », ce qui laisse croire à
// un bricolage alors que c'est la voie de mise à jour normale en conteneur.
out["engine"] = map[string]any{
"dir": engineDir(),
"in_use": engineOwns(cfgBin),
"tag": engineTagOf(cfgBin),
}
plan := detectBuildPlan()
out["plan"] = map[string]any{
"backend": plan.backend,
"arch": plan.cudaArch,
"jobs": plan.jobs,
}
// Mode « binaires précompilés » : installé ? utilisé par la config ?
pbTag, _ := prebuiltVersion()
pbBin := prebuiltServerBin()
out["prebuilt"] = map[string]any{
"tag": pbTag,
"bin": pbBin,
"dir": prebuiltDir(),
// Tout BIN vivant sous le dossier prebuilt EST le moteur précompilé, même
// s'il porte le numéro d'une release remplacée depuis (chemin versionné).
"in_use": pbBin != "" && prebuiltOwns(cfgBin),
}
out["backends_dir"] = filepath.Join(LokiHome(), "backends")
out["reco"] = recommendedMode(plan.backend)
out["job"] = lcJobSnapshot(0, false)
sendJSON(w, 200, out)
}
// providedEngineBin renvoie le moteur livré par l'image Docker, ou "" hors
// conteneur. L'image le déclare elle-même via LOKI_ENGINE_BIN (voir Dockerfile
// et docker-entrypoint.sh) : pas de chemin en dur ici, et n'importe quelle image
// de base convient tant qu'elle pose cette variable.
//
// Sans cette notion, l'UI proposait d'installer un moteur déjà présent et
// l'utilisateur tombait sur « outils manquants : cmake » — l'image ne contient
// volontairement ni cmake ni compilateur, puisqu'elle part de l'image officielle
// llama.cpp où llama-server est déjà compilé.
func providedEngineBin() string {
p := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN"))
if p == "" {
return ""
}
if st, err := os.Stat(prebuiltResolveBin(p)); err != nil || st.IsDir() {
return ""
}
return p
}
// engineProvided : le moteur vient-il de l'image ? (raccourci de lecture)
func engineProvided() bool { return providedEngineBin() != "" }
// refuseIfProvided coupe court aux jobs d'installation/compilation quand le
// moteur vient de l'image : rien à installer, et aucun compilateur disponible.
// Renvoie true si la requête a été refusée (l'appelant doit sortir).
func refuseIfProvided(w http.ResponseWriter) bool {
bin := providedEngineBin()
if bin == "" {
return false
}
sendJSON(w, 409, map[string]any{"ok": false, "error": "Le moteur est fourni par l'image Docker (" + bin +
") : il n'y a rien à compiler ici, et l'image ne contient ni cmake ni compilateur. " +
"Pour le mettre à jour, utilise « mettre à jour le moteur » dans Réglages → Moteur."})
return true
}
// samePath compare deux chemins en neutralisant séparateurs, symlinks et casse
// (Windows).
func samePath(a, b string) bool {
if a == "" || b == "" {
return false
}
norm := func(p string) string {
if real, err := filepath.EvalSymlinks(p); err == nil {
p = real
}
p = filepath.Clean(p)
return strings.ToLower(filepath.ToSlash(p))
}
return norm(a) == norm(b)
}
// handleLlamacppCheck fait un vrai git fetch puis renvoie le retard sur origin
// et le dernier commit distant. Synchrone (quelques secondes réseau).
func handleLlamacppCheck(w http.ResponseWriter, r *http.Request) {
repo := llamacppRepoDir()
if !isDir(filepath.Join(repo, ".git")) {
sendJSON(w, 200, map[string]any{"ok": false, "error": "llama.cpp n'est pas installé"})
return
}
branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "" || branch == "HEAD" {
branch = "master"
}
if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": "git fetch a échoué : " + err.Error()})
return
}
behind := 0
if b := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); b != "" {
behind, _ = strconv.Atoi(b)
}
sendJSON(w, 200, map[string]any{
"ok": true,
"behind": behind,
"local": gitOutput(repo, "rev-parse", "--short", "HEAD"),
"remote": gitOutput(repo, "rev-parse", "--short", "origin/"+branch),
"remote_date": gitOutput(repo, "log", "-1", "--format=%ci", "origin/"+branch),
"remote_msg": gitOutput(repo, "log", "-1", "--format=%s", "origin/"+branch),
"branch": branch,
"has_binary": llamaServerBin(repo) != "",
"needs_rebuild": behind > 0 || llamaServerBin(repo) == "",
})
}
// handleLlamacppInstall lance le job d'installation (clone + build + BIN).
func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) {
if refuseIfProvided(w) {
return
}
var req struct {
Force bool `json:"force"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := startLcJob("install", func() { lcRunInstall(req.Force) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleLlamacppInstallCustom lance un job d'installation d'un backend CUSTOM
// (fork llama.cpp) depuis une URL de dépôt Git : cloné dans backends/<name> et
// compilé pour la machine, SANS toucher au BIN global. Il apparaît ensuite dans
// /api/backends et se choisit par modèle (éditeur de preset → Moteur).
func handleLlamacppInstallCustom(w http.ResponseWriter, r *http.Request) {
if refuseIfProvided(w) {
return
}
var req struct {
Repo string `json:"repo"`
Name string `json:"name"`
Ref string `json:"ref"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if strings.TrimSpace(req.Repo) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "URL du dépôt requise"})
return
}
if err := startLcJob("custom", func() { lcRunCustomInstall(req.Repo, req.Name, req.Ref) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// lcRunCustomInstall : corps du job d'install d'un backend custom (sans bascule
// de BIN — c'est volontaire, il se rattache par preset).
func lcRunCustomInstall(url, name, ref string) {
bin, err := installCustomBackend(url, name, ref, lcPhase)
if err != nil {
lcFail(err)
return
}
lcAppend("binaire compilé : " + bin)
lcAppend("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.")
lcDone("backend custom installé")
}
// handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart).
// {clean:true} force une recompilation from scratch même sans nouveau commit.
func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) {
if refuseIfProvided(w) {
return
}
var req struct {
Clean bool `json:"clean"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := startLcJob("update", func() { lcRunUpdate(req.Clean) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleLlamacppPrebuiltCheck interroge la dernière release officielle de
// llama.cpp et la compare à la version précompilée installée. Synchrone.
func handleLlamacppPrebuiltCheck(w http.ResponseWriter, r *http.Request) {
tag, assets, err := fetchLlamaLatest()
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
main, _, label, _, err := pickPrebuilt(assets)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
cur, _ := prebuiltVersion()
sendJSON(w, 200, map[string]any{
"ok": true,
"latest": tag,
"current": cur,
"variant": label,
"size_mb": main.Size / 1_000_000,
"update": cur != tag || prebuiltServerBin() == "",
})
}
// handleLlamacppPrebuilt lance le job de téléchargement / mise à jour des
// binaires précompilés officiels (pas de compilation).
func handleLlamacppPrebuilt(w http.ResponseWriter, r *http.Request) {
if err := startLcJob("prebuilt", lcRunPrebuilt); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// lcRunPrebuilt : télécharge les binaires officiels, pointe BIN dessus, en
// stoppant le service pendant le remplacement (binaire verrouillé en cours
// d'exécution) puis en le relançant.
func lcRunPrebuilt() {
svcWasUp := serviceIsActive()
if svcWasUp {
lcPhase("arrêt du service le temps de l'installation…")
if err := serviceAction("stop"); err != nil {
lcAppend("[warn] impossible d'arrêter le service : " + err.Error())
}
}
bin, err := prebuiltInstall(lcAppend, lcPhase)
if err == nil {
if serr := SetConfigKey("BIN", bin); serr != nil {
err = fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", serr)
} else {
lcAppend("BIN mis à jour")
}
}
if svcWasUp {
lcPhase("redémarrage du service…")
if serr := serviceAction("start"); serr != nil {
lcAppend("[warn] redémarrage du service échoué : " + serr.Error())
}
}
if err != nil {
lcFail(err)
return
}
tag, _ := prebuiltVersion()
lcDone("binaires précompilés installés (" + tag + ")")
}
// handleLlamacppUse bascule BIN entre deux versions DÉJÀ installées, sans
// rien recompiler : "fast" = binaires précompilés, "opt" = build local. Le
// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que
// quand la version cible existe déjà (sinon elle lance un job d'installation).
func handleLlamacppUse(w http.ResponseWriter, r *http.Request) {
var req struct {
Mode string `json:"mode"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
var bin string
switch req.Mode {
case "fast":
bin = prebuiltServerBin()
case "opt":
bin = llamaServerBin(llamacppRepoDir())
default:
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu"})
return
}
if bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette version n'est pas installée"})
return
}
if err := SetConfigKey("BIN", bin); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if serviceIsActive() {
_ = serviceAction("restart")
}
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
}
// handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis
// l'offset absolu ?from=N (le client mémorise `next` et enchaîne).
func handleLlamacppJob(w http.ResponseWriter, r *http.Request) {
from, _ := strconv.Atoi(r.URL.Query().Get("from"))
sendJSON(w, 200, lcJobSnapshot(from, true))
}
// lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie
// juste l'entête (imbriquée dans /api/llamacpp).
func lcJobSnapshot(from int, withLines bool) map[string]any {
lcMu.Lock()
defer lcMu.Unlock()
if lcCur == nil {
return map[string]any{"exists": false}
}
j := lcCur
out := map[string]any{
"exists": true,
"action": j.Action,
"running": j.Running,
"phase": j.Phase,
"compiled": j.Compiled,
"error": j.Err,
"started_at": j.StartedAt,
"ended_at": j.EndedAt,
"old": j.OldCommit,
"new": j.NewCommit,
}
if withLines {
start := from - j.base
if start < 0 {
start = 0
}
if start > len(j.lines) {
start = len(j.lines)
}
out["lines"] = append([]string(nil), j.lines[start:]...)
out["next"] = j.base + len(j.lines)
}
return out
}
// ---------------------------------------------------------------------------
// Corps des jobs (miroir web de llamacppInstall / llamacppUpdate, sans stdout
// interactif : tout passe par lcPhase / le sink de build)
// ---------------------------------------------------------------------------
func lcRunInstall(force bool) {
repo := defaultRepoDir()
lcPhase("vérification des outils (git, cmake, compilateur)…")
if err := requireTools("git", "cmake"); err != nil {
lcFail(err)
return
}
if err := ensureCompiler(); err != nil {
lcFail(err)
return
}
ensureAccelerator()
if isDir(filepath.Join(repo, ".git")) {
if !force {
// Dépôt déjà là : on bascule sur une mise à jour (même intention).
lcPhase("dépôt déjà présent — bascule en mise à jour")
lcRunUpdate(false)
return
}
lcPhase("suppression du dépôt existant (--force)…")
if err := os.RemoveAll(repo); err != nil {
lcFail(err)
return
}
}
if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil {
lcFail(err)
return
}
lcPhase("clone de llama.cpp…")
if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil {
lcFail(fmt.Errorf("git clone a échoué : %w", err))
return
}
if !lcBuildAndSwitch(repo, true) {
return
}
lcDone("installation terminée")
}
func lcRunUpdate(clean bool) {
lcPhase("vérification des outils (git, cmake, compilateur)…")
if err := requireTools("git", "cmake"); err != nil {
lcFail(err)
return
}
if err := ensureCompiler(); err != nil {
lcFail(err)
return
}
ensureAccelerator()
repo := llamacppRepoDir()
if !isDir(filepath.Join(repo, ".git")) {
lcFail(fmt.Errorf("aucun dépôt llama.cpp (%s) — lance d'abord l'installation", repo))
return
}
oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
lcMu.Lock()
lcCur.OldCommit = oldCommit
lcMu.Unlock()
branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "" || branch == "HEAD" {
branch = "master"
}
lcPhase("git fetch origin…")
if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil {
lcFail(fmt.Errorf("git fetch a échoué : %w", err))
return
}
localRev := gitOutput(repo, "rev-parse", "HEAD")
remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch)
if localRev != "" && localRev == remoteRev && !clean && llamaServerBin(repo) != "" {
lcDone("déjà à jour (" + oldCommit + ") — rien à faire")
return
}
if localRev != remoteRev {
lcPhase("git pull origin/" + branch + "…")
if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil {
lcFail(fmt.Errorf("git pull a échoué (modifs locales ?) : %w", err))
return
}
}
newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
lcMu.Lock()
lcCur.NewCommit = newCommit
lcMu.Unlock()
// Le binaire en cours d'exécution ne peut pas être réécrit → stop du service
// pendant le build, redémarrage après (même en échec).
svcWasUp := serviceIsActive()
if svcWasUp {
lcPhase("arrêt du service le temps du build…")
if err := serviceAction("stop"); err != nil {
lcAppend("[warn] impossible d'arrêter le service : " + err.Error())
}
}
ok := lcBuildAndSwitch(repo, clean)
if svcWasUp {
lcPhase("redémarrage du service…")
if err := serviceAction("start"); err != nil {
lcAppend("[warn] redémarrage du service échoué : " + err.Error())
}
}
if !ok {
return
}
if oldCommit == newCommit {
lcDone("recompilé (" + newCommit + ")")
} else {
lcDone("mis à jour : " + oldCommit + " → " + newCommit)
}
}
// lcBuildAndSwitch détecte le plan, compile llama-server et pointe BIN dessus.
// Renvoie false (job en échec) si une étape casse.
func lcBuildAndSwitch(repo string, clean bool) bool {
plan := detectBuildPlan()
lcAppend(fmt.Sprintf("plan de build : backend=%s arch=%s jobs=%d", plan.backend, plan.cudaArch, plan.jobs))
lcPhase("configuration CMake…")
if err := buildLlamacpp(repo, plan, clean); err != nil {
lcAppendLogTail(filepath.Join(repo, "configure.log"), filepath.Join(repo, "build.log"))
lcFail(err)
return false
}
bin := llamaServerBin(repo)
if bin == "" {
lcFail(fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build")))
return false
}
lcAppend("binaire compilé : " + bin)
if err := SetConfigKey("BIN", bin); err != nil {
lcFail(fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err))
return false
}
lcAppend("BIN mis à jour")
return true
}
// lcAppendLogTail remonte la fin des logs de build dans le job pour que
// l'erreur réelle soit visible dans l'UI sans aller chercher les fichiers.
func lcAppendLogTail(paths ...string) {
for _, p := range paths {
b, err := os.ReadFile(p)
if err != nil {
continue
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) > 30 {
lines = lines[len(lines)-30:]
}
lcAppend("--- fin de " + filepath.Base(p) + " ---")
for _, l := range lines {
lcAppend(l)
}
}
}