feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)

Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri).

#35 — Serveur de modèle local intégré (llama.cpp) :
- domain: model_server.rs (agrégat + statut), ports ModelServerProbe /
  ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements
  model_server_status_changed et agent_launch_failed.
- application: use case EnsureLocalModelServer branché sur LaunchAgent.
- infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime,
  LocalManagedProcess, FsModelServerRegistry.
- app-tauri: DTO plat LocalModelServerConfigDto, commandes
  list/save/delete_model_server avec garde model_server_in_use, wiring.

#36 — Profils OpenCode locaux multiples :
- domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id.
- application: use case CloneOpenCodeProfileFromSeed.
- app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring.

Tests verts (exécution réelle) : domain+application OK, app-tauri
dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2,
application model_server+profile_usecases 19/19, cargo build workspace Finished.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:50:08 +02:00
parent 2e98f1fbb9
commit b82ac76f8b
24 changed files with 3147 additions and 94 deletions

View File

@ -13,15 +13,17 @@ use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime {
}
}
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
impl SeqIds {
fn new(ids: Vec<uuid::Uuid>) -> Self {
Self(Mutex::new(ids))
}
}
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> uuid::Uuid {
self.0.lock().unwrap().remove(0)
}
}
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
let store = FakeProfileStore::default();
let clone = CloneOpenCodeProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
);
let out = clone
.execute(CloneOpenCodeProfileFromSeedInput {
name: Some("OpenCode llama.cpp 2".to_owned()),
opencode: Some(
OpenCodeConfig::new(
"http://localhost:8081/v1",
None,
"qwen3-coder-70b",
Some(false),
Some(true),
)
.unwrap(),
),
})
.await
.unwrap();
let seed_id = reference_profile_id("opencode-llamacpp");
assert_ne!(out.profile.id, seed_id);
assert_eq!(
out.profile.id,
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
);
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::OpenCode)
);
assert_eq!(
out.profile
.opencode
.as_ref()
.map(|config| config.base_url.as_str()),
Some("http://localhost:8081/v1")
);
let profiles = store.0.lock().unwrap().profiles.clone();
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].id, out.profile.id);
}
#[tokio::test]
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
let store = FakeProfileStore::default();
let save = SaveProfile::new(Arc::new(store.clone()));
let mut seed = reference_profiles()
.into_iter()
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
.expect("seed exists");
seed.name = "Edited local OpenCode seed".to_owned();
save.execute(SaveProfileInput {
profile: seed.clone(),
})
.await
.unwrap();
let clone = CloneOpenCodeProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
);
let out = clone
.execute(CloneOpenCodeProfileFromSeedInput {
name: None,
opencode: None,
})
.await
.unwrap();
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
assert_eq!(out.profile.opencode, seed.opencode);
let profiles = store.0.lock().unwrap().profiles.clone();
assert_eq!(
profiles
.iter()
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
.count(),
1,
"the canonical seed must be preserved, not recreated as a duplicate"
);
assert_eq!(
profiles
.iter()
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
.count(),
2,
"ProfileId is the identity: multiple OpenCode profiles can coexist"
);
}
// ---------------------------------------------------------------------------
// ReferenceProfiles / catalogue
// ---------------------------------------------------------------------------