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

@ -34,28 +34,30 @@ use crate::dto::{
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState;
@ -793,6 +795,25 @@ pub async fn save_profile(
.map_err(ErrorDto::from)
}
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
/// from the canonical `opencode-llamacpp` seed/template.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a
/// blank requested name).
#[tauri::command]
pub async fn clone_opencode_profile_from_seed(
request: CloneOpenCodeProfileFromSeedRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.clone_opencode_profile_from_seed
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `delete_profile` — delete a profile by id.
///
/// # Errors
@ -836,6 +857,73 @@ pub async fn configure_profiles(
.map_err(ErrorDto::from)
}
/// `list_model_servers` — list configured local model servers.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn list_model_servers(
state: State<'_, AppState>,
) -> Result<ModelServerConfigListDto, ErrorDto> {
state
.list_model_servers
.execute()
.await
.map(ModelServerConfigListDto::from)
.map_err(ErrorDto::from)
}
/// `save_model_server` — upsert a local model server config.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn save_model_server(
request: SaveModelServerRequestDto,
state: State<'_, AppState>,
) -> Result<ModelServerConfigDto, ErrorDto> {
let server_id = parse_model_server_id(&request.config.id)?;
let existing = state
.list_model_servers
.execute()
.await
.map_err(ErrorDto::from)?
.servers
.into_iter()
.find(|config| config.id == server_id);
let input = save_model_server_input(request, existing.as_ref())?;
state
.save_model_server
.execute(input)
.await
.map(ModelServerConfigDto::from)
.map_err(ErrorDto::from)
}
/// `delete_model_server` — delete a local model server config when unused.
///
/// # Errors
/// Returns `model_server_in_use` if any OpenCode profile still references it.
#[tauri::command]
pub async fn delete_model_server(
server_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let server_id = parse_model_server_id(&server_id)?;
state
.delete_model_server
.execute(application::DeleteModelServerInput { server_id })
.await
.map_err(model_server_command_error)
}
fn model_server_command_error(err: AppError) -> ErrorDto {
match err {
AppError::ModelServer { code, message } => ErrorDto { code, message },
other => ErrorDto::from(other),
}
}
// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------

View File

@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto {
// ---------------------------------------------------------------------------
use application::{
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput,
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability,
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
SaveProfileInput, SaveProfileOutput,
};
use domain::profile::AgentProfile;
use domain::profile::{AgentProfile, OpenCodeConfig};
use domain::ProfileId;
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
@ -776,6 +777,12 @@ impl From<SaveProfileOutput> for ProfileDto {
}
}
impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
Self(out.profile)
}
}
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -838,6 +845,27 @@ impl From<SaveProfileRequestDto> for SaveProfileInput {
}
}
/// Request DTO for `clone_opencode_profile_from_seed`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloneOpenCodeProfileFromSeedRequestDto {
/// Optional display name for the new profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Optional OpenCode config override. When omitted, the seed config is copied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<OpenCodeConfig>,
}
impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
Self {
name: dto.name,
opencode: dto.opencode,
}
}
}
/// Request DTO for `configure_profiles` (closes the first run).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -879,6 +907,252 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
}
}
// ---------------------------------------------------------------------------
// Local model servers (B35)
// ---------------------------------------------------------------------------
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint,
};
use domain::{LocalModelServerId, StopPolicy};
/// Local model-server implementation on the IPC wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ModelServerKindDto {
/// llama.cpp `llama-server`.
LlamaCpp,
}
impl From<LocalModelServerKind> for ModelServerKindDto {
fn from(kind: LocalModelServerKind) -> Self {
match kind {
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
}
}
}
impl From<ModelServerKindDto> for LocalModelServerKind {
fn from(kind: ModelServerKindDto) -> Self {
match kind {
ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
}
}
}
/// Stop policy on the IPC wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StopPolicyDto {
/// Keep the process alive.
KeepAlive,
/// Stop when no longer used.
StopWhenUnused,
/// Stop when the application exits.
StopOnAppExit,
}
impl From<StopPolicy> for StopPolicyDto {
fn from(policy: StopPolicy) -> Self {
match policy {
StopPolicy::KeepAlive => Self::KeepAlive,
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
}
}
}
impl From<StopPolicyDto> for StopPolicy {
fn from(policy: StopPolicyDto) -> Self {
match policy {
StopPolicyDto::KeepAlive => Self::KeepAlive,
StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
}
}
}
/// A local model-server config crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerConfigDto {
/// Stable local server config id.
pub id: String,
/// Server implementation kind.
pub kind: ModelServerKindDto,
/// Display name.
pub name: String,
/// OpenAI-compatible base URL.
#[serde(rename = "baseURL")]
pub base_url: String,
/// TCP port.
pub port: u16,
/// Optional explicit `.gguf` model path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_path: Option<String>,
/// Served model name exposed to OpenCode.
pub served_model_name: String,
/// Optional explicit `llama-server` executable path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_path: Option<String>,
/// Extra argv entries.
#[serde(default)]
pub args: Vec<String>,
/// Whether IdeA should start the server lazily.
pub auto_start: bool,
/// Stop policy.
pub stop_policy: StopPolicyDto,
}
impl ModelServerConfigDto {
/// Maps a domain config to the flat IPC DTO.
#[must_use]
pub fn from_domain(config: LocalModelServerConfig) -> Self {
Self {
id: config.id.to_string(),
kind: config.kind.into(),
name: config.name,
base_url: config.endpoint.base_url,
port: config.endpoint.port,
model_path: config
.model
.path
.map(|path| path.as_str().to_owned()),
served_model_name: config.model.served_name,
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
args: config.args,
auto_start: config.auto_start,
stop_policy: config.stop_policy.into(),
}
}
/// Maps the flat IPC DTO into the domain config, preserving internal model id
/// from the existing config when available.
///
/// # Errors
/// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
pub fn into_domain(
self,
existing: Option<&LocalModelServerConfig>,
) -> Result<LocalModelServerConfig, ErrorDto> {
let server_id = parse_model_server_id(&self.id)?;
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
.map_err(invalid_domain_error)?;
let model_path = optional_non_empty(self.model_path)
.map(ModelPath::new)
.transpose()
.map_err(invalid_domain_error)?;
let model_id = existing
.filter(|config| config.id == server_id)
.map(|config| config.model.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
let model = LocalModelRef::new(
model_id,
label,
model_path,
self.served_model_name.clone(),
)
.map_err(invalid_domain_error)?;
let binary = optional_non_empty(self.binary_path)
.map(ExecutablePath::new)
.transpose()
.map_err(invalid_domain_error)?;
LocalModelServerConfig::new(
server_id,
self.kind.into(),
self.name,
endpoint,
model,
binary,
self.args,
self.auto_start,
self.stop_policy.into(),
)
.map_err(invalid_domain_error)
}
}
/// List response for local model servers.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);
impl From<ListModelServersOutput> for ModelServerConfigListDto {
fn from(out: ListModelServersOutput) -> Self {
Self(
out.servers
.into_iter()
.map(ModelServerConfigDto::from_domain)
.collect(),
)
}
}
/// Request DTO for `save_model_server`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveModelServerRequestDto {
/// Config to upsert.
pub config: ModelServerConfigDto,
}
impl From<SaveModelServerOutput> for ModelServerConfigDto {
fn from(out: SaveModelServerOutput) -> Self {
Self::from_domain(out.config)
}
}
/// Parses a local model-server id string.
///
/// # Errors
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(LocalModelServerId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid model server id: {raw}"),
})
}
/// Builds a save-model-server input after the caller has resolved the existing
/// config, if any.
///
/// # Errors
/// [`ErrorDto`] if DTO-to-domain validation fails.
pub fn save_model_server_input(
request: SaveModelServerRequestDto,
existing: Option<&LocalModelServerConfig>,
) -> Result<SaveModelServerInput, ErrorDto> {
Ok(SaveModelServerInput {
config: request.config.into_domain(existing)?,
})
}
fn optional_non_empty(raw: Option<String>) -> Option<String> {
raw.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
let primary = primary.trim();
if primary.is_empty() {
fallback.trim().to_owned()
} else {
primary.to_owned()
}
}
fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
ErrorDto {
code: "INVALID".to_owned(),
message: error.to_string(),
}
}
// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------

View File

@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter};
use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use domain::model_server::ModelServerLifecycleStatus;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
use infrastructure::TokioBroadcastEventBus;
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
pub const DOMAIN_EVENT: &str = "domain://event";
/// Dedicated Tauri event for local model-server status changes.
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
/// Dedicated Tauri event for launch failures.
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";
/// Model-server lifecycle status on the Tauri wire.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum ModelServerStatusDto {
/// No server is configured for this profile.
NotConfigured,
/// Readiness probing is in progress.
Probing,
/// IdeA is starting the process.
Starting,
/// Server is ready.
#[serde(rename_all = "camelCase")]
Ready {
/// `true` when an existing server was reused.
reused: bool,
},
/// Server preparation failed.
#[serde(rename_all = "camelCase")]
Failed {
/// Stable failure code.
code: String,
/// Human-readable message.
message: String,
},
}
impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
fn from(status: &ModelServerLifecycleStatus) -> Self {
match status {
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
ModelServerLifecycleStatus::Probing => Self::Probing,
ModelServerLifecycleStatus::Starting => Self::Starting,
ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
code: code.clone(),
message: message.clone(),
},
}
}
}
/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerStatusChangedDto {
/// Local model server id.
pub server_id: String,
/// Lifecycle status.
pub status: ModelServerStatusDto,
}
/// Payload for [`AGENT_LAUNCH_FAILED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentLaunchFailedDto {
/// Agent id.
pub agent_id: String,
/// Failure cause namespace.
pub cause: String,
/// Stable error code.
pub code: String,
/// Human-readable message.
pub message: String,
}
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
///
@ -42,6 +112,26 @@ pub enum DomainEventDto {
/// Session id.
session_id: String,
},
/// An agent launch failed before runtime session creation.
#[serde(rename_all = "camelCase")]
AgentLaunchFailed {
/// Agent id.
agent_id: String,
/// Failure cause namespace.
cause: String,
/// Stable error code.
code: String,
/// Human-readable message.
message: String,
},
/// Local model-server status changed.
#[serde(rename_all = "camelCase")]
ModelServerStatusChanged {
/// Local model server id.
server_id: String,
/// Lifecycle status.
status: ModelServerStatusDto,
},
/// An agent exited.
#[serde(rename_all = "camelCase")]
AgentExited {
@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(),
session_id: session_id.to_string(),
},
DomainEvent::AgentLaunchFailed {
agent_id,
cause,
code,
message,
} => Self::AgentLaunchFailed {
agent_id: agent_id.to_string(),
cause: cause.clone(),
code: code.clone(),
message: message.clone(),
},
DomainEvent::ModelServerStatusChanged { server_id, status } => {
Self::ModelServerStatusChanged {
server_id: server_id.to_string(),
status: status.into(),
}
}
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
agent_id: agent_id.to_string(),
code: *code,
@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
}
let dto = DomainEventDto::from(&event);
let _ = app.emit(DOMAIN_EVENT, dto);
match &event {
DomainEvent::ModelServerStatusChanged { server_id, status } => {
let _ = app.emit(
MODEL_SERVER_STATUS_CHANGED,
ModelServerStatusChangedDto {
server_id: server_id.to_string(),
status: status.into(),
},
);
}
DomainEvent::AgentLaunchFailed {
agent_id,
cause,
code,
message,
} => {
let _ = app.emit(
AGENT_LAUNCH_FAILED,
AgentLaunchFailedDto {
agent_id: agent_id.to_string(),
cause: cause.clone(),
code: code.clone(),
message: message.clone(),
},
);
}
_ => {}
}
}
// The bus dropped some events for this slow receiver; keep going.
Err(RecvError::Lagged(_)) => continue,
@ -1008,13 +1143,47 @@ mod tests {
use super::*;
use domain::ids::AgentId;
use domain::mailbox::TicketId;
use domain::ProjectId;
use domain::{LocalModelServerId, ProjectId};
use serde_json::json;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn server(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
server_id: server(35),
status: ModelServerLifecycleStatus::Ready { reused: true },
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "modelServerStatusChanged");
assert_eq!(json["serverId"], server(35).to_string());
assert_eq!(json["status"]["state"], "ready");
assert_eq!(json["status"]["reused"], true);
}
#[test]
fn agent_launch_failed_relays_model_server_cause() {
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
agent_id: agent(36),
cause: "model_server".to_owned(),
code: "path_not_accessible".to_owned(),
message: "path not accessible: /models/missing.gguf".to_owned(),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLaunchFailed");
assert_eq!(json["agentId"], agent(36).to_string());
assert_eq!(json["cause"], "model_server");
assert_eq!(json["code"], "path_not_accessible");
}
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
/// le front badge). Garantit le câblage présentation de la détection de stall.

View File

@ -106,6 +106,8 @@ pub fn run() {
// If we killed first, the registry would be empty and every
// agent would be persisted as "closed".
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let model_servers =
std::sync::Arc::clone(&state.ensure_local_model_server);
let open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
@ -119,6 +121,7 @@ pub fn run() {
for h in handles {
let _ = pty.kill(&h).await;
}
let _ = model_servers.stop_on_app_exit().await;
});
}
}
@ -156,8 +159,12 @@ pub fn run() {
commands::detect_profiles,
commands::list_profiles,
commands::save_profile,
commands::clone_opencode_profile_from_seed,
commands::delete_profile,
commands::configure_profiles,
commands::list_model_servers,
commands::save_model_server,
commands::delete_model_server,
commands::list_embedder_profiles,
commands::save_embedder_profile,
commands::delete_embedder_profile,

View File

@ -14,32 +14,32 @@ use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject,
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill,
DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory,
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles,
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -69,10 +69,11 @@ use infrastructure::{
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem,
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
@ -776,6 +777,8 @@ pub struct AppState {
pub list_profiles: Arc<ListProfiles>,
/// Save (upsert) a profile.
pub save_profile: Arc<SaveProfile>,
/// Create a new OpenCode profile instance from the canonical seed.
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
/// Delete a profile.
pub delete_profile: Arc<DeleteProfile>,
/// Persist the batch of chosen profiles (closes the first run).
@ -784,6 +787,14 @@ pub struct AppState {
pub reference_profiles: Arc<ReferenceProfiles>,
/// Whether the first-run wizard should show + the reference catalogue.
pub first_run_state: Arc<FirstRunState>,
/// Ensures local llama.cpp model servers for OpenCode profiles.
pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
/// Lists local model server configurations.
pub list_model_servers: Arc<ListModelServers>,
/// Saves local model server configurations.
pub save_model_server: Arc<SaveModelServer>,
/// Deletes local model server configurations when unused.
pub delete_model_server: Arc<DeleteModelServer>,
/// The local PTY adapter, kept port-typed so the presentation layer can
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
pub pty_port: Arc<dyn PtyPort>,
@ -1183,11 +1194,41 @@ impl AppState {
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
Arc::clone(&profile_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
let reference_profiles = Arc::new(ReferenceProfiles::new());
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
let model_server_registry = Arc::new(FsModelServerRegistry::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new(
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
Arc::new(HttpOpenAiCompatibleProbe::default())
as Arc<dyn domain::ports::ModelServerProbe>,
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
Arc::clone(&fs_port),
Arc::clone(&events_port),
));
let model_server_registry_port =
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(
&model_server_registry_port,
)));
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
&model_server_registry_port,
)));
let delete_model_server = Arc::new(DeleteModelServer::new(
Arc::clone(&model_server_registry_port),
Arc::clone(&profile_store_port),
));
let pty_bridge = Arc::new(PtyBridge::new());
// Twin of the PTY bridge for structured chat sessions (§17.7): routes a
// turn's ReplyChunks to the owning chat cell and retains the conversation
@ -1499,7 +1540,8 @@ impl AppState {
// parse ⇒ section omise, jamais d'échec de lancement.
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>),
}) as Arc<dyn LiveStateLeanProvider>)
.with_local_model_server(Arc::clone(&ensure_local_model_server)),
);
// Inter-agent launcher: same context, memory, permissions and live-state
@ -1530,6 +1572,7 @@ impl AppState {
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>)
.with_local_model_server(Arc::clone(&ensure_local_model_server))
.with_structured(
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
@ -2196,10 +2239,15 @@ impl AppState {
detect_profiles,
list_profiles,
save_profile,
clone_opencode_profile_from_seed,
delete_profile,
configure_profiles,
reference_profiles,
first_run_state,
ensure_local_model_server,
list_model_servers,
save_model_server,
delete_model_server,
pty_port,
terminal_sessions,
event_bus,