Files
Loki/internal/loki/web_llamacpp.go
T
MichaelandClaude Opus 5.5 9bffff78c2 Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset
demandait de dupliquer, basculer et comparer des bench complets un par un.
L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni
à ce que calcule le modèle, et n'écrit rien sans un clic.

- Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie
  de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur
  127.0.0.1 et un port libre, groupe de processus tué en sortie
- Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage +
  binaire) vérifié par serviceAction start/restart et par le chat, les tâches,
  bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé
  d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur
- Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit
  et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande
- EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de
  chaque essai composée à blanc et comparée à la référence (« dénature »),
  contexte, slots et cache KV recontrôlés une fois chargé
- Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour
  type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages)
- Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit
  clé par clé, vérifié par une sonde et rétabli en cas d'échec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:11 +02:00

681 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 {
// Compiler, installer ou changer de moteur l'arrête et le relance : pas pendant
// une optimisation (backend_tune_lock.go).
if err := tuneGuard(); err != nil {
return err
}
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) {
if tuneDenyHTTP(w) {
return
}
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)
}
}
}