Lot 41 : compilation de llama.cpp depuis les sources tenue par la façade

Avec LOKI_BACKENDS=rust, la façade compile elle-même llama.cpp sous Linux
et macOS (backends/build.rs) : install {force}, update {clean} et
install-custom {repo, name, ref}, port de backend_build.go, des jobs
lcRunInstall / lcRunUpdate / lcBuildAndSwitch et d'installCustomBackend.

- plan complet (Metal, CUDA avec racine du toolkit et architectures, HIP,
  Vulkan), build/ déplacé ou propre mis de côté en build.old ;
- étapes suivies : sorties des deux flux dans un même tube, découpage du
  sinkWriter (git) et du scanner (CMake), configure.log et build.log,
  fin des journaux remontée en cas d'échec ;
- outils manquants installés au mieux, compilateur vérifié ;
- service moteur arrêté le temps du build et relancé même en échec ;
- refus comme le Go : moteur de l'image, URL absente, optimisation en
  cours, job déjà lancé.

Côté Go, facadeBackendsRoutes gagne les trois routes hors Windows (la
chaîne MSVC reste au Go, qui rapporte ses étapes au job de la façade).

Parité : internal/loki/parity_build_test.go tire un journal de 15 étapes
(tests/parity_backends/build.json) que tests/backends.rs rejoue par la
façade ; tests unitaires du découpage, du plan, du cache et des erreurs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 04:54:32 +00:00
1 parent 90d6dd4a9a
commit 5197ee2542
10 files changed
+2403 -15

No files matched your search

+1 -1
View File
@@ -238,7 +238,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
@@ -0,0 +1,133 @@
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
## Contexte
Depuis le lot 22, la façade tient les backends llama.cpp quand
`LOKI_BACKENDS=rust` :
- statut, vérification git ;
- binaires précompilés ;
- bascule fast/opt ;
- backends custom ;
- le job des moteurs.
La compilation depuis les sources restait au Go. Il en rapportait chaque
étape au job de la façade par l'API interne
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
- `POST /api/llamacpp/install` `{force}` ;
- `POST /api/llamacpp/update` `{clean}` ;
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
## Le lot
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
- `backend_build.go` : plan, CMake, étapes suivies ;
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
(`web_llamacpp.go`) ;
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
(`backend_llamacpp.go`) ;
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
Le port couvre :
- **le plan complet** (`detectBuildPlan`) :
- drapeaux Release, natif, UI de llama-server coupée ;
- Metal sous macOS ;
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
compilateur explicites hors `/usr`, architectures lues par
`nvidia-smi` ;
- HIP, puis Vulkan ;
- **CMake** :
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
`build.old` ;
- nvcc dans le PATH et `CUDACXX` ;
- `configure.log` et `build.log` dans le dépôt ;
- erreurs « configuration CMake échouée » et « compilation échouée » ;
- **les étapes suivies** :
- sorties standard et d'erreur dans un même tube, ordre gardé ;
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
les 10 s, reste sans fin de ligne perdu) ;
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
plus de 1 Mio qui arrête la lecture) ;
- octets invalides en U+FFFD un à un ;
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
programme introuvable) ;
- **les outils** :
- git et cmake, puis cc, c++, make ;
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
(sudo hors root) ;
- sinon le message du Go ;
- **les jobs** :
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
en mise à jour ; `force` : supprimé puis recloné ;
- mise à jour : fetch, pull au besoin, commits avant et après ;
« déjà à jour » sans recompiler ; service arrêté le temps du build et
relancé même en échec ;
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
toucher à BIN.
Les routes répondent comme le Go :
- moteur fourni par l'image : 409 et le message du Go ;
- custom sans URL : 400 « URL du dépôt requise » ;
- optimisation en cours : 409 ;
- job déjà lancé : 409 ;
- sinon 200 `{"ok":true}` et le job tourne.
Le service moteur est celui de la façade si elle le pilote, sinon celui du
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
façade :
- générateur Visual Studio ;
- intégration CUDA de MSBuild ;
- `CUDA_PATH`.
Les messages du terminal du Go (outils manquants, dépendance connue absente
après un configure en échec) vont à la sortie d'erreur de la façade.
## Parité
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
porte :
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
- BIN ;
- les dépôts qui ont un `build.old`.
Il couvre :
- mise à jour sans dépôt ;
- installation, puis installation sur un dépôt présent et installation
forcée ;
- déjà à jour, nouveau commit, recompilation propre ;
- build en échec (fin des journaux) ;
- cmake absent, compilateur absent ;
- clone en échec ;
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
existant sur un ref (URL `git@`).
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
Les tests unitaires de `build.rs` couvrent :
- la barre de progression (`TestSinkWriterProgression`) ;
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
démesurée) ;
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
- le cache CMake déplacé ;
- noms et URL des backends custom ;
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
architectures, HIP, Vulkan) ;
- les erreurs des commandes (code de sortie, signal, programme absent,
journal écrit).
+19 -6
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
@@ -18,12 +19,13 @@ import (
// suivi du job, backends custom et GPU vus par un moteur
// (rust/loki-rs/src/backends).
//
// La façade détient alors LE job des moteurs. Ceux que le Go fait encore
// tourner — compilation depuis les sources (install, update, install-custom,
// que la façade lui relaie), moteur de l'image, Strata — lui rapportent chaque
// étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail, lcDone,
// startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure plus de
// job au démarrage (la façade s'en charge).
// La façade détient alors LE job des moteurs, et compile elle-même llama.cpp
// depuis les sources sous Linux et macOS (install, update, install-custom ;
// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation
// sous Windows (chaîne MSVC), moteur de l'image, Strata — lui rapportent
// chaque étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail,
// lcDone, startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure
// plus de job au démarrage (la façade s'en charge).
const facadeBackendsEnv = "LOKI_BACKENDS"
@@ -47,6 +49,17 @@ var facadeBackendsRoutes = map[string]bool{
"/api/backends/devices": true,
}
func init() {
// La compilation depuis les sources part aussi à la façade (lot 41), sauf
// sous Windows : générateur Visual Studio et intégration CUDA de MSBuild
// restent au Go.
if runtime.GOOS != "windows" {
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
facadeBackendsRoutes[p] = true
}
}
}
// facadeJobClient : appels courts (une ligne de journal, une phase).
var facadeJobClient = &http.Client{Timeout: 30 * time.Second}
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
@@ -954,10 +955,10 @@ func TestFacadeBackends(t *testing.T) {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
// La compilation reste au Go.
// La compilation depuis les sources part aussi (lot 41), sauf sous Windows.
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
if facadeBackendsRoutes[p] {
t.Fatalf("%s ne doit pas partir à la façade", p)
if facadeBackendsRoutes[p] != (runtime.GOOS != "windows") {
t.Fatalf("%s : tenu par la façade = %v sous %s", p, facadeBackendsRoutes[p], runtime.GOOS)
}
}
mu.Lock()
+238
View File
@@ -0,0 +1,238 @@
//go:build linux
package loki
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// parity_build_test.go — journal de référence de la compilation de llama.cpp
// depuis les sources (lot 41 de la migration Rust) : installation, mise à jour,
// recompilation propre, échec du build, outils manquants, backends custom.
// Les outils (git, cmake, compilateur) sont de faux scripts rangés dans le
// journal, seuls sur le PATH ; le job est rejoué jusqu'au bout et sa vue
// (lignes, phase, erreur, commits), BIN et la présence de build.old sont
// relevés après chaque étape. Rangé dans
// rust/loki-rs/tests/parity_backends/build.json ; tests/backends.rs le
// rejoue par la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteCompilation
const parityBuildFile = "../../rust/loki-rs/tests/parity_backends/build.json"
// Les faux outils : le dépôt « distant » est un fichier à côté des scripts.
var parityBuildTools = map[string]string{
"git": `#!/bin/sh
d=$(dirname "$0")
case "$1" in
clone)
dir="$4"
if [ -f "$(dirname "$dir")/CLONE_FAIL" ]; then echo "fatal: repository '$3' not found" >&2; exit 128; fi
mkdir -p "$dir/.git"
cat "$d/remote-sha" > "$dir/.git/HEAD_SHA"
echo "Cloning into '$dir'..." >&2 ;;
fetch)
exit 0 ;;
rev-parse)
case "$2" in
--short)
if [ "$3" = HEAD ]; then head -c 7 .git/HEAD_SHA; else head -c 7 "$d/remote-sha"; fi
echo ;;
--abbrev-ref) echo master ;;
HEAD) cat .git/HEAD_SHA ;;
*) cat "$d/remote-sha" ;;
esac ;;
pull)
cat "$d/remote-sha" > .git/HEAD_SHA
echo "Fast-forward" ;;
checkout)
echo "$2" > .git/REF
echo "HEAD is now at $2" >&2 ;;
esac
`,
"cmake": `#!/bin/sh
if [ "$1" = "-B" ]; then
mkdir -p build
echo "-- The C compiler identification is GNU 13.2.0"
echo "-- Configuring done (0.5s)"
printf 'CMAKE_HOME_DIRECTORY:INTERNAL=%s\n' "$(pwd)" > build/CMakeCache.txt
echo "-- Build files have been written to: build"
exit 0
fi
if [ -f FAIL_BUILD ]; then
echo "[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o"
echo "common/arg.cpp:12:5: error: 'boom' was not declared in this scope"
exit 2
fi
echo "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o"
echo "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o"
echo "[100%] Linking CXX executable ../bin/llama-server"
mkdir -p build/bin
printf '#!/bin/sh\necho llama-server\n' > build/bin/llama-server
chmod +x build/bin/llama-server
`,
"cc": "#!/bin/sh\n",
"c++": "#!/bin/sh\n",
"make": "#!/bin/sh\n",
}
type parityBuildStep struct {
Name string `json:"name"`
Remote string `json:"remote,omitempty"` // sha du dépôt distant
Files map[string]string `json:"files,omitempty"` // relatifs à LOKI_HOME ; "-" supprime
NoTools []string `json:"no_tools,omitempty"` // faux outils retirés pour l'étape
Action string `json:"action"` // install, update, custom
Force bool `json:"force,omitempty"`
Clean bool `json:"clean,omitempty"`
URL string `json:"url,omitempty"`
BName string `json:"bname,omitempty"`
Ref string `json:"ref,omitempty"`
Job map[string]any `json:"job"`
Bin string `json:"bin"`
BuildOld []string `json:"build_old"` // dépôts qui ont un build.old
}
type parityBuildJournal struct {
Tools map[string]string `json:"tools"`
Steps []parityBuildStep `json:"steps"`
}
var parityBuildJobsRe = regexp.MustCompile(`jobs=[0-9]+`)
func TestPariteCompilation(t *testing.T) {
home := testHome(t)
fake := filepath.Join(t.TempDir(), "outils")
sys := filepath.Join(t.TempDir(), "sys")
for _, d := range []string{fake, sys} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
for _, c := range []string{"mkdir", "cat", "head", "dirname", "chmod"} {
p, err := exec.LookPath(c)
if err != nil {
t.Fatal(err)
}
if err := os.Symlink(p, filepath.Join(sys, c)); err != nil {
t.Fatal(err)
}
}
writeTool := func(name string) {
if err := os.WriteFile(filepath.Join(fake, name), []byte(parityBuildTools[name]), 0o755); err != nil {
t.Fatal(err)
}
}
for name := range parityBuildTools {
writeTool(name)
}
t.Setenv("PATH", fake+string(os.PathListSeparator)+sys)
t.Setenv("LOKI_ENGINE_BIN", "")
mask := func(s string) string {
s = strings.ReplaceAll(s, home, "{{HOME}}")
return parityBuildJobsRe.ReplaceAllString(s, "jobs={{N}}")
}
var steps []parityBuildStep
run := func(st parityBuildStep) {
if st.Remote != "" {
if err := os.WriteFile(filepath.Join(fake, "remote-sha"), []byte(st.Remote+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
parityApplyFiles(t, home, st.Files)
for _, n := range st.NoTools {
_ = os.Remove(filepath.Join(fake, n))
}
var body func()
switch st.Action {
case "install":
body = func() { lcRunInstall(st.Force) }
case "update":
body = func() { lcRunUpdate(st.Clean) }
case "custom":
body = func() { lcRunCustomInstall(st.URL, st.BName, st.Ref) }
}
if err := startLcJob(st.Action, body); err != nil {
t.Fatalf("%s : %v", st.Name, err)
}
deadline := time.Now().Add(20 * time.Second)
for {
if snap := lcJobSnapshot(0, true); snap["running"] == false {
break
}
if time.Now().After(deadline) {
t.Fatalf("%s : le job ne finit pas", st.Name)
}
time.Sleep(10 * time.Millisecond)
}
snap := lcJobSnapshot(0, true)
delete(snap, "started_at")
delete(snap, "ended_at")
b, _ := json.Marshal(snap)
var job map[string]any
_ = json.Unmarshal([]byte(mask(string(b))), &job)
st.Job = job
st.Bin = mask(ReadConfig()["BIN"])
st.BuildOld = []string{}
entries, _ := os.ReadDir(filepath.Join(home, "backends"))
for _, e := range entries {
if isDir(filepath.Join(home, "backends", e.Name(), "build.old")) {
st.BuildOld = append(st.BuildOld, e.Name())
}
}
for _, n := range st.NoTools {
writeTool(n)
}
steps = append(steps, st)
}
run(parityBuildStep{Name: "mise à jour sans dépôt", Remote: "aaaaaaa1111111111111111111111111111111", Action: "update"})
run(parityBuildStep{Name: "installation", Action: "install"})
run(parityBuildStep{Name: "mise à jour : déjà à jour", Action: "update"})
run(parityBuildStep{Name: "mise à jour : nouveau commit", Remote: "bbbbbbb2222222222222222222222222222222", Action: "update"})
run(parityBuildStep{Name: "recompilation propre", Action: "update", Clean: true})
run(parityBuildStep{Name: "installation sur un dépôt présent", Remote: "ccccccc3333333333333333333333333333333", Action: "install"})
run(parityBuildStep{Name: "build en échec", Remote: "ddddddd4444444444444444444444444444444",
Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "1"}, Action: "update"})
run(parityBuildStep{Name: "installation forcée", Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "-"},
Action: "install", Force: true})
run(parityBuildStep{Name: "cmake absent", Action: "update", NoTools: []string{"cmake"}})
run(parityBuildStep{Name: "compilateur absent", Action: "update", NoTools: []string{"cc", "make"}})
run(parityBuildStep{Name: "clone en échec", Files: map[string]string{"backends/llama.cpp": "-", "backends/CLONE_FAIL": "1"},
Action: "install"})
run(parityBuildStep{Name: "custom : URL invalide", Files: map[string]string{"backends/CLONE_FAIL": "-"},
Action: "custom", URL: "ftp://exemple/x"})
run(parityBuildStep{Name: "custom : nouveau fork", Action: "custom", URL: "https://github.com/prism/llama.cpp.git"})
run(parityBuildStep{Name: "custom : nom assaini, sur un ref", Action: "custom", URL: "https://github.com/prism/llama.cpp.git", BName: "Mon Fork/../x", Ref: "v2"})
run(parityBuildStep{Name: "custom : dossier existant sur un ref", Action: "custom", URL: "git@github.com:prism/llama.cpp.git", Ref: "v3"})
got, err := json.MarshalIndent(parityBuildJournal{Tools: parityBuildTools, Steps: steps}, "", " ")
if err != nil {
t.Fatal(err)
}
got = append(got, '\n')
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityBuildFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityBuildFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityBuildFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if !bytes.Equal(got, want) {
t.Errorf("la compilation ne produit plus le journal rangé dans %s.\n"+
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityBuildFile)
}
}
+978
View File
@@ -0,0 +1,978 @@
//! Compilation de llama.cpp depuis les sources (lot 41) — port de
//! `backend_build.go` (plan, CMake, étapes suivies), de `lcRunInstall`,
//! `lcRunUpdate`, `lcBuildAndSwitch` (`web_llamacpp.go`) et de
//! `installCustomBackend` (`backend_llamacpp.go`), pour Linux et macOS.
//!
//! Chaque étape passe par le job des moteurs : phases, sortie des commandes
//! ligne à ligne (fichiers compilés et phases tirés des lignes), journaux
//! `configure.log` et `build.log` dans le dépôt, fin des journaux remontée
//! en cas d'échec. Les outils manquants sont installés au mieux par le
//! gestionnaire de paquets de la machine, comme le Go.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use super::probe::{Probe, is_dir};
use super::{Backends, Log, llama_server_bin, sanitize_backend_name, service, service_stop_if_up};
use crate::api::Ctx;
use crate::store::BK_CONFIG;
use crate::system::go_lossy;
/// `llamacppRepoURL`.
pub const REPO_URL: &str = "https://github.com/ggml-org/llama.cpp.git";
/// `sinkProgressEvery` : une barre qui se redessine (\r), montrée toutes les
/// 10 s.
const PROGRESS_EVERY: Duration = Duration::from_secs(10);
/// Une ligne de sortie lue au plus (`bufio.Scanner`, 1 Mio).
const MAX_LINE: usize = 1 << 20;
/// `buildPlan` complet : backend, drapeaux CMake, nvcc.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
pub backend: &'static str,
pub cuda_arch: String,
pub jobs: usize,
pub flags: Vec<String>,
pub cuda_cxx: String,
}
/// `cudaToolkitRoot` : `<racine>/bin/nvcc` → `<racine>`, sauf `/usr` (la
/// racine n'apprend alors rien à CMake).
pub fn cuda_toolkit_root(nvcc: &Path) -> String {
let Some(bin) = nvcc.parent() else {
return String::new();
};
if bin.file_name().and_then(|n| n.to_str()) != Some("bin") {
return String::new();
}
let root = bin
.parent()
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_default();
match root.as_str() {
"" | "." | "/" | "/usr" => String::new(),
_ => root,
}
}
impl Probe {
/// `detectBuildPlan`.
pub async fn build_plan(&self) -> Plan {
let base = self.detect_build_plan().await;
let mut p = Plan {
backend: base.backend,
cuda_arch: base.cuda_arch,
jobs: base.jobs,
flags: [
"-DCMAKE_BUILD_TYPE=Release",
"-DGGML_NATIVE=ON",
// L'UI de llama-server demande npm : Loki a la sienne.
"-DLLAMA_BUILD_UI=OFF",
"-DLLAMA_USE_PREBUILT_UI=OFF",
]
.map(String::from)
.to_vec(),
cuda_cxx: String::new(),
};
match p.backend {
"metal" => p.flags.push("-DGGML_METAL=ON".into()),
"cuda" => {
let nvcc = self.find_nvcc().unwrap_or_default();
p.cuda_cxx = nvcc.to_string_lossy().into_owned();
p.flags.push("-DGGML_CUDA=ON".into());
p.flags.push("-DGGML_CUDA_F16=ON".into());
let root = cuda_toolkit_root(&nvcc);
if !root.is_empty() {
p.flags.push(format!("-DCUDAToolkit_ROOT={root}"));
p.flags
.push(format!("-DCMAKE_CUDA_COMPILER={}", p.cuda_cxx));
}
if !p.cuda_arch.is_empty() {
p.flags
.push(format!("-DCMAKE_CUDA_ARCHITECTURES={}", p.cuda_arch));
}
}
"hip" => p.flags.push("-DGGML_HIP=ON".into()),
"vulkan" => p.flags.push("-DGGML_VULKAN=ON".into()),
_ => {}
}
p
}
}
/// `cacheStale` : un `build/` configuré pour un autre dossier source (dépôt
/// déplacé) ne se reconfigure pas en place.
pub fn cache_stale(build: &Path, repo: &Path) -> bool {
let Ok(b) = std::fs::read_to_string(build.join("CMakeCache.txt")) else {
return false;
};
let abs = std::path::absolute(repo).unwrap_or_else(|_| repo.to_path_buf());
let abs = abs.to_string_lossy();
for line in b.split('\n') {
if line.starts_with("CMAKE_HOME_DIRECTORY:")
&& let Some(i) = line.find('=')
{
let home = line[i + 1..].trim();
return !home.is_empty() && home != abs;
}
}
false
}
/// L'erreur d'une commande, au format d'`exec` du Go.
fn exit_err(status: std::process::ExitStatus) -> Result<(), String> {
if status.success() {
return Ok(());
}
match status.code() {
Some(c) => Err(format!("exit status {c}")),
None => {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
if let Some(s) = status.signal() {
return Err(format!("signal: {}", signal_name(s)));
}
}
Err(status.to_string())
}
}
}
#[cfg(unix)]
fn signal_name(s: i32) -> &'static str {
match s {
libc::SIGKILL => "killed",
libc::SIGTERM => "terminated",
libc::SIGINT => "interrupt",
libc::SIGSEGV => "segmentation fault",
libc::SIGABRT => "aborted",
_ => "signal",
}
}
/// Une commande lancée par une étape : sorties standard et d'erreur dans le
/// même tube (leur ordre gardé), lues ligne à ligne (voir [`Splitter`]) et
/// copiées dans `log_file`, créé avant le lancement comme au Go.
struct Running {
rx: tokio::sync::mpsc::UnboundedReceiver<String>,
done: tokio::task::JoinHandle<Result<(), String>>,
}
fn spawn_lines(
probe: &Probe,
dir: Option<&Path>,
env: &[(String, String)],
name: &str,
args: &[String],
progress: bool,
log_file: Option<&Path>,
) -> Result<Running, String> {
let mut file = log_file.and_then(|p| std::fs::File::create(p).ok());
let Some(bin) = probe.look_path(name) else {
return Err(format!(
"exec: \"{name}\": executable file not found in $PATH"
));
};
let (mut reader, writer) = std::io::pipe().map_err(|e| e.to_string())?;
let mut cmd = std::process::Command::new(&bin);
cmd.args(args)
.stdin(Stdio::null())
.stdout(writer.try_clone().map_err(|e| e.to_string())?)
.stderr(writer);
if let Some(d) = dir {
cmd.current_dir(d);
}
for (k, v) in env {
cmd.env(k, v);
}
let spawned = cmd.spawn();
// Les extrémités d'écriture n'appartiennent plus qu'à l'enfant : le tube
// se ferme à sa fin.
drop(cmd);
let mut child =
spawned.map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let done = tokio::task::spawn_blocking(move || {
split_lines(&mut reader, progress, |l| {
if let Some(f) = file.as_mut() {
use std::io::Write as _;
let _ = writeln!(f, "{l}");
}
let _ = tx.send(l);
});
drop(reader);
exit_err(child.wait().map_err(|e| e.to_string())?)
});
Ok(Running { rx, done })
}
/// [`spawn_lines`], chaque ligne versée au job.
#[allow(clippy::too_many_arguments)]
async fn run_lines(
probe: &Probe,
log: &Log<'_>,
dir: Option<&Path>,
env: &[(String, String)],
name: &str,
args: &[String],
progress: bool,
log_file: Option<&Path>,
) -> Result<(), String> {
let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?;
while let Some(line) = r.rx.recv().await {
log.line(&line).await;
}
r.done.await.map_err(|e| e.to_string())?
}
/// [`spawn_lines`], chaque ligne passée à `f` (tests).
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
async fn run_lines_with(
probe: &Probe,
dir: Option<&Path>,
env: &[(String, String)],
name: &str,
args: &[String],
progress: bool,
log_file: Option<&Path>,
mut f: impl FnMut(String),
) -> Result<(), String> {
let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?;
while let Some(line) = r.rx.recv().await {
f(line);
}
r.done.await.map_err(|e| e.to_string())?
}
/// Le découpage en lignes de `run_lines` : `sinkWriter` (`progress`) ou
/// `bufio.Scanner`.
pub struct Splitter {
progress: bool,
buf: Vec<u8>,
/// Dernière barre de progression montrée.
pub last_prog: Option<Instant>,
/// Ligne démesurée : le scanner s'est arrêté.
pub stopped: bool,
}
impl Splitter {
pub fn new(progress: bool) -> Self {
Splitter {
progress,
buf: Vec::new(),
last_prog: None,
stopped: false,
}
}
/// Une écriture de la commande.
pub fn feed(&mut self, p: &[u8], emit: &mut impl FnMut(String)) {
if self.stopped {
return;
}
self.buf.extend_from_slice(p);
let progress = self.progress;
while let Some(i) = self
.buf
.iter()
.position(|&c| c == b'\n' || (progress && c == b'\r'))
{
if self.buf[i] == b'\n' {
let mut line = &self.buf[..i];
if line.last() == Some(&b'\r') {
line = &line[..line.len() - 1];
}
if !progress && line.len() > MAX_LINE {
self.stopped = true;
return;
}
emit(go_lossy(line));
self.buf.drain(..=i);
continue;
}
// \r : fin de ligne Windows ou barre qui se redessine ; le \n
// peut arriver dans l'écriture suivante.
if i + 1 >= self.buf.len() {
break;
}
if self.buf[i + 1] == b'\n' {
emit(go_lossy(&self.buf[..i]));
self.buf.drain(..i + 2);
continue;
}
let prog = go_lossy(&self.buf[..i]).trim().to_string();
if !prog.is_empty() && self.last_prog.is_none_or(|t| t.elapsed() >= PROGRESS_EVERY) {
self.last_prog = Some(Instant::now());
emit(prog);
}
self.buf.drain(..=i);
}
if !progress && self.buf.len() > MAX_LINE {
self.stopped = true;
}
}
/// Fin du flux : le scanner rend le dernier morceau, le `sinkWriter` le
/// perd.
pub fn finish(&mut self, emit: &mut impl FnMut(String)) {
if self.progress || self.stopped || self.buf.is_empty() {
return;
}
let mut line = &self.buf[..];
if line.last() == Some(&b'\r') {
line = &line[..line.len() - 1];
}
emit(go_lossy(line));
self.buf.clear();
}
}
fn split_lines(r: &mut impl std::io::Read, progress: bool, mut emit: impl FnMut(String)) {
let mut sp = Splitter::new(progress);
let mut chunk = [0u8; 8192];
loop {
match r.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => sp.feed(&chunk[..n], &mut emit),
}
if sp.stopped {
return;
}
}
sp.finish(&mut emit);
}
/// `runStep` : une commande dont chaque ligne va au job.
pub async fn run_step(
probe: &Probe,
log: &Log<'_>,
dir: Option<&Path>,
name: &str,
args: &[&str],
) -> Result<(), String> {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
run_lines(probe, log, dir, &[], name, &args, true, None).await
}
/// `buildLlamacpp` : configure puis compile la cible llama-server. Un
/// `build/` configuré ailleurs (ou `clean`) repart de zéro (`build.old`).
pub async fn build_llamacpp(
probe: &Probe,
log: &Log<'_>,
repo: &Path,
p: &Plan,
clean: bool,
) -> Result<(), String> {
let build = repo.join("build");
if (clean || cache_stale(&build, repo)) && is_dir(&build) {
let old = repo.join("build.old");
let _ = std::fs::remove_dir_all(&old);
if std::fs::rename(&build, &old).is_err() {
let _ = std::fs::remove_dir_all(&build);
}
}
// nvcc dans le PATH et exposé par CUDACXX pour la configuration CMake.
let mut env: Vec<(String, String)> = Vec::new();
if p.backend == "cuda" && !p.cuda_cxx.is_empty() {
let cuda_bin = Path::new(&p.cuda_cxx)
.parent()
.map(|d| d.to_string_lossy().into_owned())
.unwrap_or_default();
env.push(("CUDACXX".into(), p.cuda_cxx.clone()));
let path = std::env::var("PATH").unwrap_or_default();
env.push(("PATH".into(), format!("{cuda_bin}:{path}")));
}
let mut cfg_args: Vec<String> = ["-B", "build", "-S", "."].map(String::from).to_vec();
cfg_args.extend(p.flags.iter().cloned());
let cfg_log = repo.join("configure.log");
if let Err(e) = run_lines(
probe,
log,
Some(repo),
&env,
"cmake",
&cfg_args,
false,
Some(&cfg_log),
)
.await
{
hint_missing_dep(probe, p, &cfg_log);
return Err(format!("configuration CMake échouée: {e}"));
}
let build_args: Vec<String> = [
"--build",
"build",
"--config",
"Release",
"-j",
&p.jobs.to_string(),
"--target",
"llama-server",
]
.map(String::from)
.to_vec();
run_lines(
probe,
log,
Some(repo),
&env,
"cmake",
&build_args,
false,
Some(&repo.join("build.log")),
)
.await
.map_err(|e| format!("compilation échouée: {e}"))
}
/// `hintMissingBuildDep` : une dépendance connue manquante, dite sur la
/// sortie d'erreur de la façade (le Go l'écrit sur son terminal, pas dans
/// le job).
fn hint_missing_dep(probe: &Probe, p: &Plan, cfg_log: &Path) {
let Ok(log) = std::fs::read_to_string(cfg_log) else {
return;
};
if p.backend == "cuda" && log.contains("CUDA Toolkit not found") {
eprintln!(
"[dépendance] nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable."
);
}
if p.backend == "vulkan" && log.contains("SPIRV-Headers") {
let hint = pkg_install_hint(probe, "spirv-headers");
eprintln!(
"[dépendance] en-têtes SPIR-V (paquet « SPIRV-Headers ») introuvables pour le backend Vulkan{}",
if hint.is_empty() {
String::new()
} else {
format!(" : {hint}")
}
);
}
}
/// `pkgInstallHint`.
fn pkg_install_hint(probe: &Probe, pkg: &str) -> String {
for (bin, cmd) in [
("pacman", "sudo pacman -S "),
("apt-get", "sudo apt-get install -y "),
("dnf", "sudo dnf install -y "),
("zypper", "sudo zypper install -y "),
("brew", "brew install "),
] {
if probe.has_tool(bin) {
return format!("{cmd}{pkg}");
}
}
String::new()
}
/// `autoInstallTool` : le paquet par le gestionnaire de la machine (sudo
/// hors root), sorties sur celles de la façade.
async fn auto_install_tool(probe: &Probe, name: &str) -> Result<(), String> {
for (m, install) in [
("apt-get", &["install", "-y"][..]),
("dnf", &["install", "-y"][..]),
("pacman", &["-S", "--noconfirm"][..]),
("brew", &["install"][..]),
] {
let Some(bin) = probe.look_path(m) else {
continue;
};
let mut argv: Vec<String> = vec![bin.to_string_lossy().into_owned()];
argv.extend(install.iter().map(|s| s.to_string()));
argv.push(name.to_string());
#[cfg(unix)]
let root = unsafe { libc::geteuid() } == 0;
#[cfg(not(unix))]
let root = true;
if m != "brew" && !root {
let Some(sudo) = probe.look_path("sudo") else {
return Err(format!("{m} requiert root (ni root ni sudo disponibles)"));
};
argv.insert(0, sudo.to_string_lossy().into_owned());
}
let status = tokio::process::Command::new(&argv[0])
.args(&argv[1..])
.stdin(Stdio::null())
.status()
.await
.map_err(|e| e.to_string())?;
return exit_err(status);
}
Err(format!(
"aucun gestionnaire de paquets connu — installe {name} manuellement"
))
}
/// `requireTools` : les outils manquants installés au mieux, puis revus.
pub async fn require_tools(probe: &Probe, tools: &[&str]) -> Result<(), String> {
let missing: Vec<&str> = tools
.iter()
.copied()
.filter(|t| !probe.has_tool(t))
.collect();
if missing.is_empty() {
return Ok(());
}
eprintln!(
"[info] outils manquants: {} — installation automatique…",
missing.join(", ")
);
for t in &missing {
if let Err(e) = auto_install_tool(probe, t).await {
eprintln!(" • {t}: {e}");
}
}
let still: Vec<&str> = tools
.iter()
.copied()
.filter(|t| !probe.has_tool(t))
.collect();
if !still.is_empty() {
return Err(format!(
"outils toujours manquants après tentative d'install: {} — installe-les à la main puis réessaie",
still.join(", ")
));
}
Ok(())
}
/// `ensureCompiler` (Unix) : cc, c++ et make, installés au mieux.
pub async fn ensure_compiler(probe: &Probe) -> Result<(), String> {
let any = |names: &[&str]| names.iter().any(|n| probe.has_tool(n));
if any(&["cc", "gcc", "clang"]) && any(&["c++", "g++", "clang++"]) && probe.has_tool("make") {
return Ok(());
}
let candidates: &[&str] = if probe.has_tool("apt-get") {
&["build-essential", "gcc", "g++", "make"]
} else {
&["gcc", "gcc-c++", "make"]
};
eprintln!("[info] compilateur C/C++ absent — installation via le gestionnaire de paquets…");
for pkg in candidates {
let _ = auto_install_tool(probe, pkg).await;
}
if any(&["cc", "gcc"]) && any(&["c++", "g++"]) && probe.has_tool("make") {
return Ok(());
}
Err(
"compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement"
.into(),
)
}
/// `lcAppendLogTail` : la fin des journaux de build, dans le job.
async fn append_log_tail(log: &Log<'_>, paths: &[PathBuf]) {
for p in paths {
let Ok(b) = std::fs::read(p) else { continue };
let text = String::from_utf8_lossy(&b);
let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
let tail = &lines[lines.len().saturating_sub(30)..];
let base = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
log.line(&format!("--- fin de {base} ---")).await;
for l in tail {
log.line(l).await;
}
}
}
/// `lcBuildAndSwitch` : compile llama-server et y pointe BIN. Faux : le job
/// a échoué.
async fn build_and_switch(
ctx: &Ctx,
b: &Backends,
log: &Log<'_>,
repo: &Path,
clean: bool,
) -> bool {
let plan = b.probe.build_plan().await;
log.line(&format!(
"plan de build : backend={} arch={} jobs={}",
plan.backend, plan.cuda_arch, plan.jobs
))
.await;
log.phase("configuration CMake…").await;
if let Err(e) = build_llamacpp(&b.probe, log, repo, &plan, clean).await {
append_log_tail(log, &[repo.join("configure.log"), repo.join("build.log")]).await;
b.jobs.fail(&ctx.store, &e).await;
return false;
}
let bin = llama_server_bin(repo);
if bin.is_empty() {
b.jobs
.fail(
&ctx.store,
&format!(
"build terminé mais binaire introuvable sous {}",
repo.join("build").display()
),
)
.await;
return false;
}
log.line(&format!("binaire compilé : {bin}")).await;
if let Err(e) = ctx.store.put(BK_CONFIG, "BIN", bin.into_bytes()).await {
b.jobs
.fail(
&ctx.store,
&format!("build ok mais échec d'écriture de BIN : {e}"),
)
.await;
return false;
}
log.line("BIN mis à jour").await;
true
}
/// La vérification commune des outils ; l'erreur est rangée dans le job.
async fn tools_ready(ctx: &Ctx, b: &Backends) -> bool {
let res = match require_tools(&b.probe, &["git", "cmake"]).await {
Ok(()) => ensure_compiler(&b.probe).await,
Err(e) => Err(e),
};
match res {
Ok(()) => true,
Err(e) => {
b.jobs.fail(&ctx.store, &e).await;
false
}
}
}
/// `lcRunInstall` : clone, compilation, BIN. Dépôt déjà là : une mise à
/// jour (`force` : il est supprimé et recloné).
pub async fn run_install(ctx: &Ctx, b: &Backends, force: bool) {
let log = Log {
jobs: &b.jobs,
store: &ctx.store,
};
let repo = b.default_repo_dir();
log.phase("vérification des outils (git, cmake, compilateur)…")
.await;
if !tools_ready(ctx, b).await {
return;
}
if is_dir(&repo.join(".git")) {
if !force {
log.phase("dépôt déjà présent — bascule en mise à jour")
.await;
Box::pin(run_update(ctx, b, false)).await;
return;
}
log.phase("suppression du dépôt existant (--force)…").await;
if let Err(e) = std::fs::remove_dir_all(&repo) {
b.jobs
.fail(
&ctx.store,
&crate::paths::os_err("unlinkat", &repo.to_string_lossy(), &e),
)
.await;
return;
}
}
if let Some(parent) = repo.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
b.jobs
.fail(
&ctx.store,
&crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e),
)
.await;
return;
}
log.phase("clone de llama.cpp…").await;
let repo_s = repo.to_string_lossy().into_owned();
if let Err(e) = run_step(
&b.probe,
&log,
None,
"git",
&["clone", "--depth=1", REPO_URL, &repo_s],
)
.await
{
b.jobs
.fail(&ctx.store, &format!("git clone a échoué : {e}"))
.await;
return;
}
if !build_and_switch(ctx, b, &log, &repo, true).await {
return;
}
b.jobs.done(&ctx.store, "installation terminée").await;
}
/// `lcRunUpdate` : fetch, pull au besoin, recompilation (service arrêté le
/// temps du build), BIN.
pub async fn run_update(ctx: &Ctx, b: &Backends, clean: bool) {
let log = Log {
jobs: &b.jobs,
store: &ctx.store,
};
log.phase("vérification des outils (git, cmake, compilateur)…")
.await;
if !tools_ready(ctx, b).await {
return;
}
let cfg_bin = ctx
.store
.config()
.await
.get("BIN")
.cloned()
.unwrap_or_default();
let repo = b.repo_dir(&cfg_bin);
if !is_dir(&repo.join(".git")) {
b.jobs
.fail(
&ctx.store,
&format!(
"aucun dépôt llama.cpp ({}) — lance d'abord l'installation",
repo.display()
),
)
.await;
return;
}
let old = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await;
b.jobs.set_commits(Some(&old), None).await;
let mut branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
if branch.is_empty() || branch == "HEAD" {
branch = "master".into();
}
log.phase("git fetch origin…").await;
if let Err(e) = run_step(
&b.probe,
&log,
Some(&repo),
"git",
&["fetch", "origin", "--quiet"],
)
.await
{
b.jobs
.fail(&ctx.store, &format!("git fetch a échoué : {e}"))
.await;
return;
}
let local = b.git(&repo, &["rev-parse", "HEAD"]).await;
let remote = b
.git(&repo, &["rev-parse", &format!("origin/{branch}")])
.await;
if !local.is_empty() && local == remote && !clean && !llama_server_bin(&repo).is_empty() {
b.jobs
.done(&ctx.store, &format!("déjà à jour ({old}) — rien à faire"))
.await;
return;
}
if local != remote {
log.phase(&format!("git pull origin/{branch}…")).await;
if let Err(e) = run_step(
&b.probe,
&log,
Some(&repo),
"git",
&["pull", "--ff-only", "origin", &branch],
)
.await
{
b.jobs
.fail(
&ctx.store,
&format!("git pull a échoué (modifs locales ?) : {e}"),
)
.await;
return;
}
}
let new = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await;
b.jobs.set_commits(None, Some(&new)).await;
// Le binaire en service ne se réécrit pas : le moteur s'arrête le temps
// du build, et repart après, même en échec.
let (svc_was_up, err) = service_stop_if_up(ctx).await;
if svc_was_up {
log.phase("arrêt du service le temps du build…").await;
if !err.is_empty() {
log.line(&format!("[warn] impossible d'arrêter le service : {err}"))
.await;
}
}
let ok = build_and_switch(ctx, b, &log, &repo, clean).await;
if svc_was_up {
log.phase("redémarrage du service…").await;
let (_, e) = service(ctx, "start", false).await;
if !e.is_empty() {
log.line(&format!("[warn] redémarrage du service échoué : {e}"))
.await;
}
}
if !ok {
return;
}
let msg = if old == new {
format!("recompilé ({new})")
} else {
format!("mis à jour : {old} → {new}")
};
b.jobs.done(&ctx.store, &msg).await;
}
/// `looksLikeGitURL`.
pub fn looks_like_git_url(u: &str) -> bool {
["https://", "http://", "git@", "ssh://", "git://"]
.iter()
.any(|p| u.starts_with(p))
}
/// `deriveBackendName` : le nom du dépôt, suffixé du propriétaire pour un
/// fork nommé « llama.cpp ».
pub fn derive_backend_name(url: &str) -> String {
let s = url.trim();
let s = s.strip_suffix(".git").unwrap_or(s);
let s = s.trim_end_matches('/');
let parts: Vec<&str> = s.split(['/', ':']).filter(|p| !p.is_empty()).collect();
let (repo, owner) = match parts.len() {
0 => ("", ""),
1 => (parts[0], ""),
n => (parts[n - 1], parts[n - 2]),
};
let mut name = if repo.is_empty() {
"backend".to_string()
} else {
repo.to_string()
};
if name.eq_ignore_ascii_case("llama.cpp") && !owner.is_empty() {
name = format!("{name}-{owner}");
}
sanitize_backend_name(&name)
}
/// `installCustomBackend` : clone (ou met à jour) un fork dans
/// `backends/<nom>` et le compile, sans toucher à BIN ; rend le binaire.
pub async fn install_custom(
b: &Backends,
log: &Log<'_>,
url: &str,
name: &str,
git_ref: &str,
) -> Result<String, String> {
let url = url.trim();
if url.is_empty() {
return Err("URL du dépôt vide".into());
}
if !looks_like_git_url(url) {
return Err(format!(
"URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : {url}"
));
}
let name = if name.trim().is_empty() {
derive_backend_name(url)
} else {
sanitize_backend_name(name)
};
// `backendDir` : le nom assaini ne sort jamais de backends/.
let dir = std::path::absolute(b.backends_dir())
.map_err(|e| e.to_string())?
.join(&name);
log.phase("vérification des outils (git, cmake, compilateur)…")
.await;
require_tools(&b.probe, &["git", "cmake"]).await?;
ensure_compiler(&b.probe).await?;
let probe = &b.probe;
if is_dir(&dir.join(".git")) {
log.phase("dépôt déjà présent — mise à jour…").await;
let _ = run_step(
probe,
log,
Some(&dir),
"git",
&["fetch", "origin", "--quiet"],
)
.await;
if !git_ref.is_empty() {
run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?;
} else {
let branch = b.git(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
if !branch.is_empty() && branch != "HEAD" {
let _ = run_step(
probe,
log,
Some(&dir),
"git",
&["pull", "--ff-only", "origin", &branch],
)
.await;
}
}
} else {
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e))?;
}
log.phase(&format!("clone de {url}…")).await;
let dir_s = dir.to_string_lossy().into_owned();
run_step(
probe,
log,
None,
"git",
&["clone", "--depth=1", url, &dir_s],
)
.await
.map_err(|e| format!("git clone a échoué : {e}"))?;
if !git_ref.is_empty() {
let _ = run_step(
probe,
log,
Some(&dir),
"git",
&["fetch", "--unshallow", "origin"],
)
.await;
run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?;
}
}
let plan = probe.build_plan().await;
log.phase(&format!("compilation (backend={})…", plan.backend))
.await;
build_llamacpp(probe, log, &dir, &plan, false).await?;
let bin = llama_server_bin(&dir);
if bin.is_empty() {
return Err(format!(
"build terminé mais binaire introuvable sous {}",
dir.join("build").display()
));
}
Ok(bin)
}
/// `lcRunCustomInstall` : un backend custom, sans bascule de BIN (il se
/// choisit par preset).
pub async fn run_custom(ctx: &Ctx, b: &Backends, url: &str, name: &str, git_ref: &str) {
let log = Log {
jobs: &b.jobs,
store: &ctx.store,
};
match install_custom(b, &log, url, name, git_ref).await {
Err(e) => b.jobs.fail(&ctx.store, &e).await,
Ok(bin) => {
log.line(&format!("binaire compilé : {bin}")).await;
log.line("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.")
.await;
b.jobs.done(&ctx.store, "backend custom installé").await;
}
}
}
#[cfg(test)]
mod tests;
+271
View File
@@ -0,0 +1,271 @@
use super::*;
fn feed_all(sp: &mut Splitter, writes: &[&[u8]]) -> Vec<String> {
let mut got = Vec::new();
for w in writes {
sp.feed(w, &mut |l| got.push(l));
}
sp.finish(&mut |l| got.push(l));
got
}
/// `TestSinkWriterProgression` : une barre (\r sans \n) apparaît sans noyer
/// le journal ; \r\n reste une fin de ligne, même coupé entre deux écritures.
#[test]
fn barre_de_progression() {
let mut sp = Splitter::new(true);
let mut got = Vec::new();
sp.feed(b"Step 5\r", &mut |l| got.push(l));
sp.feed(
"\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r".as_bytes(),
&mut |l| got.push(l),
);
sp.feed(" modèle : 3 / 76 GB\r".as_bytes(), &mut |l| got.push(l));
sp.last_prog = Some(Instant::now() - Duration::from_secs(60));
sp.feed(" modèle : 40 / 76 GB\r[ok] fini\n".as_bytes(), &mut |l| {
got.push(l)
});
// Un reste sans fin de ligne est perdu, comme au Go.
sp.feed(b"reste", &mut |l| got.push(l));
sp.finish(&mut |l| got.push(l));
assert_eq!(
got,
[
"Step 5",
"modèle : 1 / 76 GB",
"modèle : 3 / 76 GB",
"[ok] fini"
]
);
}
/// Le scanner : \n seul coupe, \r final retiré, dernier morceau rendu,
/// octets invalides en U+FFFD un à un.
#[test]
fn scanner_de_lignes() {
let mut sp = Splitter::new(false);
let got = feed_all(&mut sp, &[b"a\r\nb\rc\n", b"d", b"\xff\xfee\n", b"fin\r"]);
assert_eq!(got, ["a", "b\rc", "d\u{fffd}\u{fffd}e", "fin"]);
let mut sp = Splitter::new(false);
let long = vec![b'x'; MAX_LINE + 1];
let got = feed_all(&mut sp, &[b"ok\n", &long, b"\napres\n"]);
assert_eq!(got, ["ok"]);
}
/// `TestCudaToolkitRoot`.
#[test]
fn racine_du_toolkit() {
for (nvcc, want) in [
("/usr/local/cuda/bin/nvcc", "/usr/local/cuda"),
("/usr/local/cuda-12.8/bin/nvcc", "/usr/local/cuda-12.8"),
("/usr/bin/nvcc", ""),
("/opt/nvcc", ""),
("/bin/nvcc", ""),
("bin/nvcc", ""),
("", ""),
] {
assert_eq!(cuda_toolkit_root(Path::new(nvcc)), want, "{nvcc}");
}
}
#[test]
fn cache_deplace() {
let d = tempfile::tempdir().unwrap();
let repo = d.path().join("llama.cpp");
let build = repo.join("build");
std::fs::create_dir_all(&build).unwrap();
assert!(!cache_stale(&build, &repo), "sans cache");
let cache = |home: &str| {
std::fs::write(
build.join("CMakeCache.txt"),
format!("# x\nCMAKE_HOME_DIRECTORY:INTERNAL={home} \nAUTRE=1\n"),
)
.unwrap()
};
cache(&repo.to_string_lossy());
assert!(!cache_stale(&build, &repo), "même dossier");
cache("/ailleurs/llama.cpp");
assert!(cache_stale(&build, &repo), "dépôt déplacé");
cache("");
assert!(!cache_stale(&build, &repo), "chemin vide");
std::fs::write(
build.join("CMakeCache.txt"),
"CMAKE_HOME_DIRECTORY:INTERNAL\n",
)
.unwrap();
assert!(!cache_stale(&build, &repo), "sans =");
}
#[test]
fn noms_et_urls() {
for (url, want) in [
("https://github.com/prism/llama.cpp.git", "llama.cpp-prism"),
("git@github.com:prism/llama.cpp.git", "llama.cpp-prism"),
("https://github.com/ikawrakow/ik_llama.cpp/", "ik_llama.cpp"),
(" https://github.com/Owner/LLAMA.CPP ", "llama.cpp-owner"),
("llama.cpp", "llama.cpp"),
("https://", "https"),
("", "backend"),
("/", "backend"),
] {
assert_eq!(derive_backend_name(url), want, "{url}");
}
for (url, ok) in [
("https://x", true),
("http://x", true),
("git@x:y", true),
("ssh://x", true),
("git://x", true),
("ftp://x", false),
("/chemin/local", false),
(" https://x", false),
] {
assert_eq!(looks_like_git_url(url), ok, "{url}");
}
}
/// Le plan complet par machine (faux outils, fausse racine).
#[cfg(target_os = "linux")]
#[tokio::test]
async fn plan_par_machine() {
let tools = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let probe = Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: root.path().to_path_buf(),
};
let script = |name: &str, body: &str| {
let p = tools.path().join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
};
let base = [
"-DCMAKE_BUILD_TYPE=Release",
"-DGGML_NATIVE=ON",
"-DLLAMA_BUILD_UI=OFF",
"-DLLAMA_USE_PREBUILT_UI=OFF",
];
let p = probe.build_plan().await;
assert_eq!(
(p.backend, p.flags.clone(), p.cuda_cxx.as_str()),
("cpu", base.map(String::from).to_vec(), "")
);
assert!(p.jobs >= 1);
// nvcc dans le PATH seulement (racine /usr : rien à apprendre à CMake).
script("nvcc", "");
script(
"nvidia-smi",
r#"case "$1" in -L) echo "GPU 0: RTX";; --query-gpu=compute_cap) printf '8.6\n8.9\n8.6\n';; esac"#,
);
let p = probe.build_plan().await;
let nvcc = tools.path().join("nvcc").to_string_lossy().into_owned();
let mut want: Vec<String> = base.map(String::from).to_vec();
want.extend(["-DGGML_CUDA=ON".into(), "-DGGML_CUDA_F16=ON".into()]);
want.push("-DCMAKE_CUDA_ARCHITECTURES=86;89".into());
assert_eq!((p.backend, p.cuda_arch.as_str()), ("cuda", "86;89"));
// Le dossier des outils ne s'appelle pas bin : pas de racine.
assert_eq!(p.flags, want);
assert_eq!(p.cuda_cxx, nvcc);
// Un vrai toolkit l'emporte, racine explicite.
let tk = root.path().join("usr/local/cuda-12.8/bin");
std::fs::create_dir_all(&tk).unwrap();
std::fs::write(tk.join("nvcc"), "").unwrap();
let p = probe.build_plan().await;
let tk_nvcc = tk.join("nvcc").to_string_lossy().into_owned();
let tk_root = root
.path()
.join("usr/local/cuda-12.8")
.to_string_lossy()
.into_owned();
let mut want: Vec<String> = base.map(String::from).to_vec();
want.extend([
"-DGGML_CUDA=ON".into(),
"-DGGML_CUDA_F16=ON".into(),
format!("-DCUDAToolkit_ROOT={tk_root}"),
format!("-DCMAKE_CUDA_COMPILER={tk_nvcc}"),
"-DCMAKE_CUDA_ARCHITECTURES=86;89".into(),
]);
assert_eq!(p.flags, want);
assert_eq!(p.cuda_cxx, tk_nvcc);
// Sans compute_cap : pas d'architecture imposée.
script("nvidia-smi", r#"[ "$1" = -L ] && echo "GPU 0: RTX""#);
let p = probe.build_plan().await;
assert!(
!p.flags
.iter()
.any(|f| f.starts_with("-DCMAKE_CUDA_ARCHITECTURES"))
);
assert_eq!(p.cuda_arch, "");
// HIP, puis Vulkan.
std::fs::remove_file(tools.path().join("nvidia-smi")).unwrap();
script("hipcc", "");
let p = probe.build_plan().await;
assert_eq!(
(p.backend, p.flags.last().unwrap().as_str()),
("hip", "-DGGML_HIP=ON")
);
std::fs::remove_file(tools.path().join("hipcc")).unwrap();
script("glslc", "");
script("vulkaninfo", "");
let p = probe.build_plan().await;
assert_eq!(
(p.backend, p.flags.last().unwrap().as_str()),
("vulkan", "-DGGML_VULKAN=ON")
);
}
/// Les étapes suivies : sortie des deux flux dans l'ordre, journal écrit,
/// erreurs au format d'`exec`.
#[cfg(unix)]
#[tokio::test]
async fn erreurs_des_commandes() {
let tools = tempfile::tempdir().unwrap();
let probe = Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: tools.path().to_path_buf(),
};
let p = tools.path().join("outil");
std::fs::write(&p, "#!/bin/sh\necho un\necho deux >&2\nprintf trois\n[ -n \"$X\" ] && echo \"X=$X\"\nexit $1\n").unwrap();
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let mut got = Vec::new();
let log = tools.path().join("x.log");
let r = run_lines_with(
&probe,
Some(tools.path()),
&[("X".into(), "1".into())],
"outil",
&args(&["3"]),
false,
Some(&log),
|l| got.push(l),
)
.await;
assert_eq!(r, Err("exit status 3".into()));
assert_eq!(got, ["un", "deux", "troisX=1"]);
assert_eq!(
std::fs::read_to_string(&log).unwrap(),
"un\ndeux\ntroisX=1\n"
);
let mut got = Vec::new();
let r = run_lines_with(&probe, None, &[], "outil", &args(&["0"]), true, None, |l| {
got.push(l)
})
.await;
assert_eq!(r, Ok(()));
assert_eq!(got, ["un", "deux"]);
let r = run_lines_with(&probe, None, &[], "absent", &[], true, None, |_| {}).await;
assert_eq!(
r,
Err("exec: \"absent\": executable file not found in $PATH".into())
);
std::fs::write(&p, "#!/bin/sh\nkill -9 $$\n").unwrap();
let r = run_lines_with(&probe, None, &[], "outil", &[], true, None, |_| {}).await;
assert_eq!(r, Err("signal: killed".into()));
}
+79 -5
View File
@@ -4,12 +4,14 @@
//! `handleBackends`, `handleBackendsCustom`, `handleLlamacppUninstallCustom`
//! (`web_api.go`) et `handleBackendDevices` (`web_devices.go`).
//!
//! La façade détient LE job des moteurs ([`job::Jobs`]) : le sien (binaires
//! précompilés) et ceux que le Go fait encore tourner — compilation depuis les
//! sources (install, update, install-custom, relayées au Go), moteur de
//! La façade détient LE job des moteurs ([`job::Jobs`]) : les siens
//! (binaires précompilés ; compilation depuis les sources — install, update,
//! install-custom — sous Linux et macOS depuis le lot 41, [`build`]) et ceux
//! que le Go fait encore tourner — compilation sous Windows, moteur de
//! l'image, Strata — qui lui rapportent chaque étape par l'API interne
//! (`/internal/facade/v1/build-job/*`).
pub mod build;
pub mod devices;
pub mod job;
pub mod prebuilt;
@@ -37,8 +39,9 @@ use probe::{Probe, is_dir, is_file};
/// Préfixe de l'API interne du job (pour le Go seulement).
pub const INTERNAL_PREFIX: &str = "/internal/facade/v1/build-job/";
/// `refuseIfProvided` n'est plus utile ici : les routes de compilation
/// restent au Go, qui répond lui-même.
const INSTALL_REQ_TYPE: &str = r#"struct { Force bool "json:\"force\"" }"#;
const UPDATE_REQ_TYPE: &str = r#"struct { Clean bool "json:\"clean\"" }"#;
const CUSTOM_REQ_TYPE: &str = r#"struct { Repo string "json:\"repo\""; Name string "json:\"name\""; Ref string "json:\"ref\"" }"#;
const USE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#;
const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#;
const BIN_REQ_TYPE: &str = r#"struct { Bin string "json:\"bin\"" }"#;
@@ -361,6 +364,15 @@ pub fn handles(path: &str) -> bool {
| "/api/backends"
| "/api/backends/custom"
| "/api/backends/devices"
) || (cfg!(unix) && is_build_route(path))
}
/// Les routes de compilation depuis les sources (lot 41 ; Unix seulement,
/// la chaîne MSVC de Windows reste au Go).
fn is_build_route(path: &str) -> bool {
matches!(
path,
"/api/llamacpp/install" | "/api/llamacpp/update" | "/api/llamacpp/install-custom"
)
}
@@ -438,6 +450,7 @@ pub async fn serve(
send_json(StatusCode::OK, &b.jobs.snapshot(go_atoi(&from), true).await)
}
"/api/llamacpp/uninstall-custom" => uninstall_custom(b, store, &raw).await,
p if cfg!(unix) && is_build_route(p) => start_build(ctx, b, p, &raw).await,
"/api/backends" => {
let list: Vec<Value> = b
.list_backend_bins()
@@ -486,6 +499,67 @@ pub async fn serve(
}
}
/// `handleLlamacppInstall`, `handleLlamacppUpdate`,
/// `handleLlamacppInstallCustom` : le job de compilation lancé.
async fn start_build(ctx: &Ctx, b: &Arc<Backends>, path: &str, raw: &[u8]) -> Response<Body> {
let provided = b.provided_engine_bin();
if !provided.is_empty() {
return fail(
StatusCode::CONFLICT,
format!(
"Le moteur est fourni par l'image Docker ({provided}) : 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."
),
);
}
enum Run {
Install(bool),
Update(bool),
Custom(String, String, String),
}
let (action, run) = match path {
"/api/llamacpp/install" => {
let d = gojson::decode_struct(raw, INSTALL_REQ_TYPE, &[("force", Kind::Bool)]);
("install", Run::Install(d.bool("force")))
}
"/api/llamacpp/update" => {
let d = gojson::decode_struct(raw, UPDATE_REQ_TYPE, &[("clean", Kind::Bool)]);
("update", Run::Update(d.bool("clean")))
}
_ => {
let d = gojson::decode_struct(
raw,
CUSTOM_REQ_TYPE,
&[("repo", Kind::Str), ("name", Kind::Str), ("ref", Kind::Str)],
);
if d.str("repo").trim().is_empty() {
return fail(StatusCode::BAD_REQUEST, "URL du dépôt requise");
}
(
"custom",
Run::Custom(d.str("repo"), d.str("name"), d.str("ref")),
)
}
};
if let Some(e) = tune_busy(&ctx.store).await {
return fail(StatusCode::CONFLICT, e);
}
if let Err(e) = b.jobs.start(&ctx.store, action).await {
return fail(StatusCode::CONFLICT, e);
}
let (b2, ctx2) = (b.clone(), ctx.clone());
tokio::spawn(async move {
match run {
Run::Install(force) => build::run_install(&ctx2, &b2, force).await,
Run::Update(clean) => build::run_update(&ctx2, &b2, clean).await,
Run::Custom(url, name, r) => build::run_custom(&ctx2, &b2, &url, &name, &r).await,
}
b2.jobs.end(&ctx2.store).await;
});
send_json(StatusCode::OK, &json!({ "ok": true }))
}
/// Valeur d'un paramètre de requête (dernière occurrence ignorée : la
/// première, comme `Query().Get`).
fn query_param(query: &str, key: &str) -> String {
+198
View File
@@ -656,3 +656,201 @@ fn extraction_zip() {
"entrée d'archive suspecte : ../evasion"
);
}
/// Lot 41 : la compilation depuis les sources (install, update,
/// install-custom), rejouée sur le journal du Go
/// (`internal/loki/parity_build_test.go` → `parity_backends/build.json`) :
/// mêmes faux outils seuls dans le PATH des sondes, même vue du job (lignes,
/// phase, erreur, commits), même BIN, mêmes `build.old`.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn compilation_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_backends/build.json")).expect("journal");
let bn = bench().await;
let tools = fx["tools"].as_object().unwrap();
let write_tool = |name: &str| write_file(&bn.tools.join(name), s(&tools[name]), 0o755);
for name in tools.keys() {
write_tool(name);
}
let home = bn.home.to_string_lossy().into_owned();
let jobs_re = Regex::new(r"jobs=[0-9]+").unwrap();
let mask = |v: &str| {
jobs_re
.replace_all(&v.replace(&home, "{{HOME}}"), "jobs={{N}}")
.into_owned()
};
for st in fx["steps"].as_array().unwrap() {
let name = s(&st["name"]);
if let Some(r) = st["remote"].as_str() {
std::fs::write(bn.tools.join("remote-sha"), format!("{r}\n")).unwrap();
}
apply_files(&bn.home, &st["files"]);
let no_tools: Vec<&str> = st["no_tools"]
.as_array()
.into_iter()
.flatten()
.map(s)
.collect();
for n in &no_tools {
let _ = std::fs::remove_file(bn.tools.join(n));
}
let (path, body) = match s(&st["action"]) {
"install" => (
"/api/llamacpp/install",
json!({ "force": st["force"] == json!(true) }),
),
"update" => (
"/api/llamacpp/update",
json!({ "clean": st["clean"] == json!(true) }),
),
_ => (
"/api/llamacpp/install-custom",
json!({ "repo": st["url"], "name": s(&st["bname"]), "ref": s(&st["ref"]) }),
),
};
let (code, v) = bn.call("POST", path, &body.to_string()).await;
assert_eq!((code, v), (200, json!({ "ok": true })), "{name}");
bn.wait_job().await;
let (_, raw) = fakego::send(bn.facade, "GET", "/api/llamacpp/job", &[], "").await;
let mut job: Value = serde_json::from_str(&mask(&String::from_utf8_lossy(&raw))).unwrap();
let o = job.as_object_mut().unwrap();
o.remove("started_at");
o.remove("ended_at");
assert_eq!(job, st["job"], "{name} : job");
let bin = bn.go.get("config", "BIN").unwrap_or_default();
assert_eq!(mask(&bin), s(&st["bin"]), "{name} : BIN");
let mut olds: Vec<String> = std::fs::read_dir(bn.home.join("backends"))
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("build.old").is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
olds.sort();
assert_eq!(json!(olds), st["build_old"], "{name} : build.old");
for n in &no_tools {
write_tool(n);
}
}
}
/// Les refus des routes de compilation : moteur de l'image, URL absente,
/// optimisation en cours, job déjà lancé.
#[cfg(unix)]
#[tokio::test]
async fn compilation_refusee() {
let bn = bench().await;
let moteur = bn.home.join("image/llama-server");
std::fs::create_dir_all(moteur.parent().unwrap()).unwrap();
write_file(&moteur, "#!/bin/sh\n", 0o755);
bn.b.set_engine_bin_env(&moteur.to_string_lossy());
let (code, v) = bn.call("POST", "/api/llamacpp/update", "{}").await;
assert_eq!(code, 409);
assert_eq!(
v["error"],
"Le moteur est fourni par l'image Docker ({{HOME}}/image/llama-server) : 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."
);
bn.b.set_engine_bin_env("");
let (code, v) = bn
.call("POST", "/api/llamacpp/install-custom", r#"{"repo":" "}"#)
.await;
assert_eq!(
(code, v),
(400, json!({ "ok": false, "error": "URL du dépôt requise" }))
);
*bn.go.tune_busy.lock().unwrap() = Some(TUNE_BUSY.into());
let (code, v) = bn.call("POST", "/api/llamacpp/install", "{}").await;
assert_eq!((code, v), (409, json!({ "ok": false, "error": TUNE_BUSY })));
*bn.go.tune_busy.lock().unwrap() = None;
let backend = bn.go.backend.lock().unwrap().unwrap();
let store = loki_rs::store::Store::new(backend, fakego::JETON.into());
bn.b.jobs.start(&store, "prebuilt").await.unwrap();
let (code, v) = bn.call("POST", "/api/llamacpp/update", "{}").await;
assert_eq!(
(code, v),
(
409,
json!({ "ok": false, "error": "un job prebuilt est déjà en cours" })
)
);
}
/// La mise à jour arrête le moteur le temps du build et le relance, même
/// quand le build échoue.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn compilation_arrete_le_service() {
let fx: Value =
serde_json::from_str(include_str!("parity_backends/build.json")).expect("journal");
let bn = bench().await;
for (name, script) in fx["tools"].as_object().unwrap() {
write_file(&bn.tools.join(name), s(script), 0o755);
}
let remote =
|sha: &str| std::fs::write(bn.tools.join("remote-sha"), format!("{sha}\n")).unwrap();
let lines = |job: &Value| -> Vec<String> {
job["lines"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
};
remote("aaaaaaa1111111111111111111111111111111");
assert_eq!(bn.call("POST", "/api/llamacpp/install", "{}").await.0, 200);
bn.wait_job().await;
assert!(
bn.go.service_actions.lock().unwrap().is_empty(),
"l'installation ne touche pas au service"
);
*bn.go.service_active.lock().unwrap() = true;
for (sha, fail_build) in [
("bbbbbbb2222222222222222222222222222222", false),
("ccccccc3333333333333333333333333333333", true),
] {
remote(sha);
let marker = bn.home.join("backends/llama.cpp/FAIL_BUILD");
if fail_build {
std::fs::write(&marker, "1").unwrap();
}
bn.go.service_actions.lock().unwrap().clear();
assert_eq!(bn.call("POST", "/api/llamacpp/update", "{}").await.0, 200);
bn.wait_job().await;
let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await;
let l = lines(&job);
let stop = l
.iter()
.position(|x| x == "▶ arrêt du service le temps du build…")
.expect("arrêt");
let cfg = l
.iter()
.position(|x| x == "▶ configuration CMake…")
.unwrap();
let start = l
.iter()
.position(|x| x == "▶ redémarrage du service…")
.expect("relance");
assert!(stop < cfg && cfg < start, "{l:?}");
assert_eq!(
*bn.go.service_actions.lock().unwrap(),
vec![("stop".to_string(), true), ("start".to_string(), false)]
);
if fail_build {
assert_eq!(job["error"], "compilation échouée: exit status 2");
// Comme au Go : l'échec est posé, puis le service relancé.
assert_eq!(
&l[l.len() - 2..],
[
"✗ compilation échouée: exit status 2",
"▶ redémarrage du service…"
]
);
} else {
assert_eq!(l.last().unwrap(), "✓ mis à jour : aaaaaaa → bbbbbbb");
}
}
}
@@ -0,0 +1,482 @@
{
"tools": {
"c++": "#!/bin/sh\n",
"cc": "#!/bin/sh\n",
"cmake": "#!/bin/sh\nif [ \"$1\" = \"-B\" ]; then\n mkdir -p build\n echo \"-- The C compiler identification is GNU 13.2.0\"\n echo \"-- Configuring done (0.5s)\"\n printf 'CMAKE_HOME_DIRECTORY:INTERNAL=%s\\n' \"$(pwd)\" \u003e build/CMakeCache.txt\n echo \"-- Build files have been written to: build\"\n exit 0\nfi\nif [ -f FAIL_BUILD ]; then\n echo \"[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o\"\n echo \"common/arg.cpp:12:5: error: 'boom' was not declared in this scope\"\n exit 2\nfi\necho \"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o\"\necho \"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o\"\necho \"[100%] Linking CXX executable ../bin/llama-server\"\nmkdir -p build/bin\nprintf '#!/bin/sh\\necho llama-server\\n' \u003e build/bin/llama-server\nchmod +x build/bin/llama-server\n",
"git": "#!/bin/sh\nd=$(dirname \"$0\")\ncase \"$1\" in\n clone)\n dir=\"$4\"\n if [ -f \"$(dirname \"$dir\")/CLONE_FAIL\" ]; then echo \"fatal: repository '$3' not found\" \u003e\u00262; exit 128; fi\n mkdir -p \"$dir/.git\"\n cat \"$d/remote-sha\" \u003e \"$dir/.git/HEAD_SHA\"\n echo \"Cloning into '$dir'...\" \u003e\u00262 ;;\n fetch)\n exit 0 ;;\n rev-parse)\n case \"$2\" in\n --short)\n if [ \"$3\" = HEAD ]; then head -c 7 .git/HEAD_SHA; else head -c 7 \"$d/remote-sha\"; fi\n echo ;;\n --abbrev-ref) echo master ;;\n HEAD) cat .git/HEAD_SHA ;;\n *) cat \"$d/remote-sha\" ;;\n esac ;;\n pull)\n cat \"$d/remote-sha\" \u003e .git/HEAD_SHA\n echo \"Fast-forward\" ;;\n checkout)\n echo \"$2\" \u003e .git/REF\n echo \"HEAD is now at $2\" \u003e\u00262 ;;\nesac\n",
"make": "#!/bin/sh\n"
},
"steps": [
{
"name": "mise à jour sans dépôt",
"remote": "aaaaaaa1111111111111111111111111111111",
"action": "update",
"job": {
"action": "update",
"compiled": 0,
"error": "aucun dépôt llama.cpp ({{HOME}}/backends/llama.cpp) — lance d'abord l'installation",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"✗ aucun dépôt llama.cpp ({{HOME}}/backends/llama.cpp) — lance d'abord l'installation"
],
"new": "",
"next": 2,
"old": "",
"phase": "échec",
"running": false
},
"bin": "",
"build_old": []
},
{
"name": "installation",
"action": "install",
"job": {
"action": "install",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ clone de llama.cpp…",
"Cloning into '{{HOME}}/backends/llama.cpp'...",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server",
"BIN mis à jour",
"✓ installation terminée"
],
"new": "",
"next": 14,
"old": "",
"phase": "installation terminée",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "mise à jour : déjà à jour",
"action": "update",
"job": {
"action": "update",
"compiled": 0,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ git fetch origin…",
"✓ déjà à jour (aaaaaaa) — rien à faire"
],
"new": "",
"next": 3,
"old": "aaaaaaa",
"phase": "déjà à jour (aaaaaaa) — rien à faire",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "mise à jour : nouveau commit",
"remote": "bbbbbbb2222222222222222222222222222222",
"action": "update",
"job": {
"action": "update",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ git fetch origin…",
"▶ git pull origin/master…",
"Fast-forward",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server",
"BIN mis à jour",
"✓ mis à jour : aaaaaaa → bbbbbbb"
],
"new": "bbbbbbb",
"next": 15,
"old": "aaaaaaa",
"phase": "mis à jour : aaaaaaa → bbbbbbb",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "recompilation propre",
"action": "update",
"clean": true,
"job": {
"action": "update",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ git fetch origin…",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server",
"BIN mis à jour",
"✓ recompilé (bbbbbbb)"
],
"new": "bbbbbbb",
"next": 13,
"old": "bbbbbbb",
"phase": "recompilé (bbbbbbb)",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": [
"llama.cpp"
]
},
{
"name": "installation sur un dépôt présent",
"remote": "ccccccc3333333333333333333333333333333",
"action": "install",
"job": {
"action": "install",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ dépôt déjà présent — bascule en mise à jour",
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ git fetch origin…",
"▶ git pull origin/master…",
"Fast-forward",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server",
"BIN mis à jour",
"✓ mis à jour : bbbbbbb → ccccccc"
],
"new": "ccccccc",
"next": 17,
"old": "bbbbbbb",
"phase": "mis à jour : bbbbbbb → ccccccc",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": [
"llama.cpp"
]
},
{
"name": "build en échec",
"remote": "ddddddd4444444444444444444444444444444",
"files": {
"backends/llama.cpp/FAIL_BUILD": "1"
},
"action": "update",
"job": {
"action": "update",
"compiled": 2,
"error": "compilation échouée: exit status 2",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ git fetch origin…",
"▶ git pull origin/master…",
"Fast-forward",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"common/arg.cpp:12:5: error: 'boom' was not declared in this scope",
"--- fin de configure.log ---",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"--- fin de build.log ---",
"[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"common/arg.cpp:12:5: error: 'boom' was not declared in this scope",
"✗ compilation échouée: exit status 2"
],
"new": "ddddddd",
"next": 19,
"old": "ccccccc",
"phase": "échec",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": [
"llama.cpp"
]
},
{
"name": "installation forcée",
"files": {
"backends/llama.cpp/FAIL_BUILD": "-"
},
"action": "install",
"force": true,
"job": {
"action": "install",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ suppression du dépôt existant (--force)…",
"▶ clone de llama.cpp…",
"Cloning into '{{HOME}}/backends/llama.cpp'...",
"plan de build : backend=cpu arch= jobs={{N}}",
"▶ configuration CMake…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server",
"BIN mis à jour",
"✓ installation terminée"
],
"new": "",
"next": 15,
"old": "",
"phase": "installation terminée",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "cmake absent",
"no_tools": [
"cmake"
],
"action": "update",
"job": {
"action": "update",
"compiled": 0,
"error": "outils toujours manquants après tentative d'install: cmake — installe-les à la main puis réessaie",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"✗ outils toujours manquants après tentative d'install: cmake — installe-les à la main puis réessaie"
],
"new": "",
"next": 2,
"old": "",
"phase": "échec",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "compilateur absent",
"no_tools": [
"cc",
"make"
],
"action": "update",
"job": {
"action": "update",
"compiled": 0,
"error": "compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"✗ compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement"
],
"new": "",
"next": 2,
"old": "",
"phase": "échec",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "clone en échec",
"files": {
"backends/CLONE_FAIL": "1",
"backends/llama.cpp": "-"
},
"action": "install",
"job": {
"action": "install",
"compiled": 0,
"error": "git clone a échoué : exit status 128",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ clone de llama.cpp…",
"fatal: repository 'https://github.com/ggml-org/llama.cpp.git' not found",
"✗ git clone a échoué : exit status 128"
],
"new": "",
"next": 4,
"old": "",
"phase": "échec",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "custom : URL invalide",
"files": {
"backends/CLONE_FAIL": "-"
},
"action": "custom",
"url": "ftp://exemple/x",
"job": {
"action": "custom",
"compiled": 0,
"error": "URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : ftp://exemple/x",
"exists": true,
"lines": [
"✗ URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : ftp://exemple/x"
],
"new": "",
"next": 1,
"old": "",
"phase": "échec",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "custom : nouveau fork",
"action": "custom",
"url": "https://github.com/prism/llama.cpp.git",
"job": {
"action": "custom",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ clone de https://github.com/prism/llama.cpp.git…",
"Cloning into '{{HOME}}/backends/llama.cpp-prism'...",
"▶ compilation (backend=cpu)…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp-prism/build/bin/llama-server",
"→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.",
"✓ backend custom installé"
],
"new": "",
"next": 13,
"old": "",
"phase": "backend custom installé",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "custom : nom assaini, sur un ref",
"action": "custom",
"url": "https://github.com/prism/llama.cpp.git",
"bname": "Mon Fork/../x",
"ref": "v2",
"job": {
"action": "custom",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ clone de https://github.com/prism/llama.cpp.git…",
"Cloning into '{{HOME}}/backends/mon-fork-..-x'...",
"HEAD is now at v2",
"▶ compilation (backend=cpu)…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/mon-fork-..-x/build/bin/llama-server",
"→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.",
"✓ backend custom installé"
],
"new": "",
"next": 14,
"old": "",
"phase": "backend custom installé",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
},
{
"name": "custom : dossier existant sur un ref",
"action": "custom",
"url": "git@github.com:prism/llama.cpp.git",
"ref": "v3",
"job": {
"action": "custom",
"compiled": 2,
"error": "",
"exists": true,
"lines": [
"▶ vérification des outils (git, cmake, compilateur)…",
"▶ dépôt déjà présent — mise à jour…",
"HEAD is now at v3",
"▶ compilation (backend=cpu)…",
"-- The C compiler identification is GNU 13.2.0",
"-- Configuring done (0.5s)",
"-- Build files have been written to: build",
"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o",
"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o",
"[100%] Linking CXX executable ../bin/llama-server",
"binaire compilé : {{HOME}}/backends/llama.cpp-prism/build/bin/llama-server",
"→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.",
"✓ backend custom installé"
],
"new": "",
"next": 13,
"old": "",
"phase": "backend custom installé",
"running": false
},
"bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server",
"build_old": []
}
]
}