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

@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
/// parse ⇒ section omise, jamais d'échec de lancement.
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
/// Ensure local model servers referenced by OpenCode profiles before writing
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
/// when `OpenCodeConfig.localModelServerId` is set.
local_model_server: Option<Arc<EnsureLocalModelServer>>,
}
impl LaunchAgent {
@ -1174,9 +1179,17 @@ impl LaunchAgent {
provider_sessions: None,
projectors: None,
live_state_lean: None,
local_model_server: None,
}
}
/// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
self.local_model_server = Some(ensure);
self
}
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
/// `# État du projet` (status + intent des autres agents) est injecté dans le
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
@ -1550,7 +1563,7 @@ impl LaunchAgent {
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
let mut profile = self
.profiles
.list()
.await?
@ -1649,6 +1662,9 @@ impl LaunchAgent {
)
.await?;
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
@ -2336,6 +2352,60 @@ impl LaunchAgent {
}
}
}
async fn ensure_local_model_server_for_opencode(
&self,
agent: &Agent,
profile: &mut AgentProfile,
) -> Result<(), AppError> {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(());
}
let Some(opencode) = profile.opencode.as_mut() else {
return Ok(());
};
let Some(server_id) = opencode.local_model_server_id else {
return Ok(());
};
let Some(ensure) = self.local_model_server.as_ref() else {
let err = AppError::ModelServer {
code: "not_wired".to_owned(),
message:
"OpenCode profile references localModelServerId but no model server service is wired"
.to_owned(),
};
self.publish_agent_launch_failed(agent.id, &err);
return Err(err);
};
match ensure
.execute(EnsureLocalModelServerInput { server_id })
.await
{
Ok(output) => {
opencode.base_url = output.ready.base_url;
opencode.model = output.ready.model;
Ok(())
}
Err(err) => {
self.publish_agent_launch_failed(agent.id, &err);
Err(err)
}
}
}
fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
let (cause, code) = match err {
AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
_ => ("launch", err.code()),
};
self.events.publish(DomainEvent::AgentLaunchFailed {
agent_id,
cause: cause.to_owned(),
code: code.to_owned(),
message: err.to_string(),
});
}
}
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a

View File

@ -41,8 +41,10 @@ pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveProfile, SaveProfileInput, SaveProfileOutput,
};

View File

@ -13,12 +13,13 @@
use std::sync::Arc;
use domain::ports::{AgentRuntime, ProfileStore};
use domain::profile::AgentProfile;
use domain::ids::ProfileId;
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
use crate::error::AppError;
use super::catalogue::selectable_reference_profiles;
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
// ---------------------------------------------------------------------------
// DetectProfiles
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile,
}
// ---------------------------------------------------------------------------
// CloneOpenCodeProfileFromSeed
// ---------------------------------------------------------------------------
/// Input for [`CloneOpenCodeProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneOpenCodeProfileFromSeedInput {
/// Optional display name for the cloned profile. When absent, a copy label is
/// derived from the seed name.
pub name: Option<String>,
/// Optional OpenCode config override. When absent, the seed config is copied.
pub opencode: Option<OpenCodeConfig>,
}
/// Output of [`CloneOpenCodeProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneOpenCodeProfileFromSeedOutput {
/// The newly persisted profile.
pub profile: AgentProfile,
}
/// Creates a new OpenCode profile instance from the canonical seed/template.
///
/// The persisted canonical `opencode-llamacpp` profile is preferred when present,
/// so local edits are preserved as the clone template. If it is absent, the
/// in-memory reference catalogue seed is used. The new profile always receives a
/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles
/// with `StructuredAdapter::OpenCode` are therefore valid.
pub struct CloneOpenCodeProfileFromSeed {
store: Arc<dyn ProfileStore>,
ids: Arc<dyn IdGenerator>,
}
impl CloneOpenCodeProfileFromSeed {
/// Builds the use case from the profile store and id generator ports.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { store, ids }
}
/// Clones the canonical OpenCode seed into a new persisted profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the
/// requested name is blank, or [`AppError::Internal`] if the seed is malformed.
pub async fn execute(
&self,
input: CloneOpenCodeProfileFromSeedInput,
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
let existing = self.store.list().await?;
let seed_id = reference_profile_id("opencode-llamacpp");
let seed = existing
.iter()
.find(|profile| profile.id == seed_id)
.cloned()
.or_else(|| {
reference_profiles()
.into_iter()
.find(|profile| profile.id == seed_id)
})
.ok_or_else(|| {
AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into())
})?;
if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() {
return Err(AppError::Internal(
"canonical OpenCode seed is not an OpenCode profile".into(),
));
}
let mut profile = seed;
profile.id = fresh_profile_id(&*self.ids, &existing)?;
profile.name = match input.name {
Some(name) => {
if name.trim().is_empty() {
return Err(AppError::Invalid("profile.name must not be empty".into()));
}
name
}
None => format!("{} copy", profile.name),
};
if let Some(config) = input.opencode {
profile.opencode = Some(config);
}
self.store.save(&profile).await?;
Ok(CloneOpenCodeProfileFromSeedOutput { profile })
}
}
fn fresh_profile_id(
ids: &dyn IdGenerator,
existing: &[AgentProfile],
) -> Result<ProfileId, AppError> {
for _ in 0..16 {
let id = ProfileId::from_uuid(ids.new_uuid());
if existing.iter().all(|profile| profile.id != id) {
return Ok(id);
}
}
Err(AppError::Internal(
"could not allocate a unique profile id".into(),
))
}
/// Persists (creates or replaces) a single profile.
pub struct SaveProfile {
store: Arc<dyn ProfileStore>,