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, parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto, ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
}; };
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState; use crate::state::AppState;
@ -793,6 +795,25 @@ pub async fn save_profile(
.map_err(ErrorDto::from) .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. /// `delete_profile` — delete a profile by id.
/// ///
/// # Errors /// # Errors
@ -836,6 +857,73 @@ pub async fn configure_profiles(
.map_err(ErrorDto::from) .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) // Embedder profiles & engines (LOT C2 — §14.5.3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use application::{ use application::{
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
SaveProfileInput, SaveProfileOutput,
}; };
use domain::profile::AgentProfile; use domain::profile::{AgentProfile, OpenCodeConfig};
use domain::ProfileId; use domain::ProfileId;
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase /// 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. /// Request DTO for `detect_profiles`: the candidate profiles to probe.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[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). /// Request DTO for `configure_profiles` (closes the first run).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[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) // Embedder profiles & engines (LOT C2 — §14.5.3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter};
use domain::conversation::ConversationParty; use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource}; use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness; use domain::input::AgentLiveness;
use domain::model_server::ModelServerLifecycleStatus;
use domain::{IssueLinkKind, IssuePriority, IssueStatus}; use domain::{IssueLinkKind, IssuePriority, IssueStatus};
use infrastructure::TokioBroadcastEventBus; use infrastructure::TokioBroadcastEventBus;
/// Name of the Tauri event carrying relayed [`DomainEvent`]s. /// Name of the Tauri event carrying relayed [`DomainEvent`]s.
pub const DOMAIN_EVENT: &str = "domain://event"; 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). /// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
/// ///
@ -42,6 +112,26 @@ pub enum DomainEventDto {
/// Session id. /// Session id.
session_id: String, 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. /// An agent exited.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
AgentExited { AgentExited {
@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
session_id: session_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 { DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
code: *code, code: *code,
@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
} }
let dto = DomainEventDto::from(&event); let dto = DomainEventDto::from(&event);
let _ = app.emit(DOMAIN_EVENT, dto); 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. // The bus dropped some events for this slow receiver; keep going.
Err(RecvError::Lagged(_)) => continue, Err(RecvError::Lagged(_)) => continue,
@ -1008,13 +1143,47 @@ mod tests {
use super::*; use super::*;
use domain::ids::AgentId; use domain::ids::AgentId;
use domain::mailbox::TicketId; use domain::mailbox::TicketId;
use domain::ProjectId; use domain::{LocalModelServerId, ProjectId};
use serde_json::json; use serde_json::json;
fn agent(n: u128) -> AgentId { fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n)) 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 /// 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 /// `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. /// 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 // If we killed first, the registry would be empty and every
// agent would be persisted as "closed". // agent would be persisted as "closed".
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); 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 open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles(); let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
@ -119,6 +121,7 @@ pub fn run() {
for h in handles { for h in handles {
let _ = pty.kill(&h).await; let _ = pty.kill(&h).await;
} }
let _ = model_servers.stop_on_app_exit().await;
}); });
} }
} }
@ -156,8 +159,12 @@ pub fn run() {
commands::detect_profiles, commands::detect_profiles,
commands::list_profiles, commands::list_profiles,
commands::save_profile, commands::save_profile,
commands::clone_opencode_profile_from_seed,
commands::delete_profile, commands::delete_profile,
commands::configure_profiles, commands::configure_profiles,
commands::list_model_servers,
commands::save_model_server,
commands::delete_model_server,
commands::list_embedder_profiles, commands::list_embedder_profiles,
commands::save_embedder_profile, commands::save_embedder_profile,
commands::delete_embedder_profile, commands::delete_embedder_profile,

View File

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

View File

@ -0,0 +1,111 @@
//! DTO contract tests for local model-server IPC.
use app_tauri_lib::dto::{
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
save_model_server_input,
};
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint,
};
use domain::{LocalModelServerId, StopPolicy};
use serde_json::json;
use uuid::Uuid;
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(Uuid::from_u128(n))
}
fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConfig {
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"Local Qwen",
ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(),
LocalModelRef::new(
model_id,
"Qwen",
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
"qwen3-coder",
)
.unwrap(),
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
vec!["--ctx-size".to_owned(), "4096".to_owned()],
true,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[test]
fn model_server_dto_serialises_flat_camelcase_wire_shape() {
let dto = ModelServerConfigDto::from_domain(domain_config(sid(35), "internal-model-id"));
let value = serde_json::to_value(dto).unwrap();
assert_eq!(value["id"], sid(35).to_string());
assert_eq!(value["kind"], "llamaCpp");
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
assert_eq!(value["modelPath"], "/models/qwen.gguf");
assert_eq!(value["servedModelName"], "qwen3-coder");
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
assert_eq!(value["stopPolicy"], "stopOnAppExit");
assert!(value.get("model").is_none(), "domain model must not leak");
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
}
#[test]
fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
let raw = json!({
"id": sid(36).to_string(),
"kind": "llamaCpp",
"name": "Local Qwen",
"baseURL": "http://localhost:8081",
"port": 8081,
"modelPath": "/models/qwen.gguf",
"servedModelName": "qwen3-coder",
"binaryPath": "/usr/bin/llama-server",
"args": ["--ctx-size", "4096"],
"autoStart": true,
"stopPolicy": "stopOnAppExit"
});
let request = SaveModelServerRequestDto {
config: serde_json::from_value(raw).unwrap(),
};
let input = save_model_server_input(request, None).unwrap();
assert_eq!(input.config.id, sid(36));
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
assert_eq!(
input.config.model.path.unwrap().as_str(),
"/models/qwen.gguf"
);
assert_eq!(input.config.model.served_name, "qwen3-coder");
assert_eq!(input.config.model.label, "qwen3-coder");
assert!(!input.config.model.id.is_empty());
}
#[test]
fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
let existing = domain_config(sid(37), "stable-internal-id");
let dto = ModelServerConfigDto {
id: sid(37).to_string(),
kind: ModelServerKindDto::LlamaCpp,
name: "Updated Qwen".to_owned(),
base_url: "http://localhost:8082/v1".to_owned(),
port: 8082,
model_path: Some("/models/qwen-updated.gguf".to_owned()),
served_model_name: "qwen3-coder-updated".to_owned(),
binary_path: Some("/usr/bin/llama-server".to_owned()),
args: Vec::new(),
auto_start: true,
stop_policy: StopPolicyDto::StopOnAppExit,
};
let config = dto.into_domain(Some(&existing)).unwrap();
assert_eq!(config.model.id, "stable-internal-id");
assert_eq!(config.model.served_name, "qwen3-coder-updated");
}

View File

@ -3,15 +3,16 @@
//! error behaviour. //! error behaviour.
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto, parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
}; };
use application::{ use application::{
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
ProfileAvailability, SaveProfileInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
}; };
use domain::ids::ProfileId; use domain::ids::{LocalModelServerId, ProfileId};
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -94,6 +95,47 @@ fn save_request_deserialises_profile() {
assert!(input.profile.detect.is_none()); assert!(input.profile.detect.is_none());
} }
#[test]
fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35));
let raw = json!({
"name": "Local Qwen",
"opencode": {
"baseURL": "http://localhost:8081/v1",
"model": "qwen3-coder-70b",
"reasoning": false,
"attachment": true,
"localModelServerId": server_id.to_string()
}
});
let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
let input: CloneOpenCodeProfileFromSeedInput = dto.into();
assert_eq!(input.name.as_deref(), Some("Local Qwen"));
let opencode = input.opencode.expect("config override");
assert_eq!(opencode.base_url, "http://localhost:8081/v1");
assert_eq!(opencode.api_key, None);
assert_eq!(opencode.model, "qwen3-coder-70b");
assert_eq!(opencode.reasoning, Some(false));
assert_eq!(opencode.attachment, Some(true));
assert_eq!(opencode.local_model_server_id, Some(server_id));
}
#[test]
fn opencode_config_dto_omits_local_model_server_id_when_none() {
let config = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap();
let value = serde_json::to_value(&config).unwrap();
assert!(value.get("localModelServerId").is_none());
}
#[test] #[test]
fn configure_request_deserialises_profiles() { fn configure_request_deserialises_profiles() {
let raw = json!({ "profiles": [] }); let raw = json!({ "profiles": [] });

View File

@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
use crate::error::AppError; use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc}; use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
use crate::project::project_context_path; use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions}; use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean; use crate::workstate::GetLiveStateLean;
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
/// injection (zéro régression). Best-effort strict : provider absent / erreur / /// injection (zéro régression). Best-effort strict : provider absent / erreur /
/// parse ⇒ section omise, jamais d'échec de lancement. /// parse ⇒ section omise, jamais d'échec de lancement.
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>, 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 { impl LaunchAgent {
@ -1174,9 +1179,17 @@ impl LaunchAgent {
provider_sessions: None, provider_sessions: None,
projectors: None, projectors: None,
live_state_lean: 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 /// 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 /// `# État du projet` (status + intent des autres agents) est injecté dans le
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section /// convention file. Sans cet appel (cas legacy / tests existants), aucune section
@ -1550,7 +1563,7 @@ impl LaunchAgent {
.contexts .contexts
.read_context(&input.project, &agent.id) .read_context(&input.project, &agent.id)
.await?; .await?;
let profile = self let mut profile = self
.profiles .profiles
.list() .list()
.await? .await?
@ -1649,6 +1662,9 @@ impl LaunchAgent {
) )
.await?; .await?;
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ── // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn / // Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`, // `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 /// 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, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
}; };
pub use usecases::{ pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveProfile, SaveProfileInput, SaveProfileOutput,
}; };

View File

@ -13,12 +13,13 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{AgentRuntime, ProfileStore}; use domain::ids::ProfileId;
use domain::profile::AgentProfile; use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
use crate::error::AppError; use crate::error::AppError;
use super::catalogue::selectable_reference_profiles; use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DetectProfiles // DetectProfiles
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile, 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. /// Persists (creates or replaces) a single profile.
pub struct SaveProfile { pub struct SaveProfile {
store: Arc<dyn ProfileStore>, store: Arc<dyn ProfileStore>,

View File

@ -6,8 +6,8 @@
//! with one error shape when building its `ErrorDTO`. //! with one error shape when building its `ErrorDTO`.
use domain::ports::{ use domain::ports::{
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
RemoteError, RuntimeError, StoreError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
}; };
use domain::{AgentId, NodeId}; use domain::{AgentId, NodeId};
use domain::{IssueStoreError, SprintStoreError}; use domain::{IssueStoreError, SprintStoreError};
@ -38,6 +38,15 @@ pub enum AppError {
#[error("process error: {0}")] #[error("process error: {0}")]
Process(String), Process(String),
/// A local model server could not be prepared for launch.
#[error("model server error ({code}): {message}")]
ModelServer {
/// Stable model-server error code.
code: String,
/// Human-readable message.
message: String,
},
/// A git operation failed. /// A git operation failed.
#[error("git error: {0}")] #[error("git error: {0}")]
Git(String), Git(String),
@ -101,6 +110,7 @@ impl AppError {
Self::FileSystem(_) => "FILESYSTEM", Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE", Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS", Self::Process(_) => "PROCESS",
Self::ModelServer { .. } => "MODEL_SERVER",
Self::Git(_) => "GIT", Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE", Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
@ -183,6 +193,30 @@ impl From<ProcessError> for AppError {
} }
} }
impl From<ModelServerError> for AppError {
fn from(e: ModelServerError) -> Self {
Self::ModelServer {
code: model_server_error_code(&e).to_owned(),
message: e.to_string(),
}
}
}
fn model_server_error_code(err: &ModelServerError) -> &'static str {
match err {
ModelServerError::NotConfigured => "not_configured",
ModelServerError::Invalid(_) => "invalid",
ModelServerError::PermissionDenied(_) => "permission_denied",
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
ModelServerError::PortOccupied(_) => "port_occupied",
ModelServerError::InUse(_) => "model_server_in_use",
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Timeout => "timeout",
}
}
impl From<RuntimeError> for AppError { impl From<RuntimeError> for AppError {
fn from(e: RuntimeError) -> Self { fn from(e: RuntimeError) -> Self {
Self::Process(e.to_string()) Self::Process(e.to_string())

View File

@ -22,6 +22,7 @@ pub mod health;
pub mod issues; pub mod issues;
pub mod layout; pub mod layout;
pub mod memory; pub mod memory;
pub mod model_server;
pub mod orchestrator; pub mod orchestrator;
pub mod permission; pub mod permission;
pub mod project; pub mod project;
@ -39,19 +40,20 @@ pub use agent::{
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id, drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles, InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
}; };
pub use background::{ pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
@ -102,6 +104,12 @@ pub use memory::{
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };
pub use model_server::{
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
SaveModelServerInput, SaveModelServerOutput,
};
pub use orchestrator::{ pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,

View File

@ -0,0 +1,447 @@
//! Use cases for local model servers.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use domain::events::DomainEvent;
use domain::model_server::{
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
};
use domain::{LocalModelServerId, StopPolicy};
use crate::error::AppError;
/// Output of [`ListModelServers::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelServersOutput {
/// Persisted local model-server configs.
pub servers: Vec<LocalModelServerConfig>,
}
/// Lists local model-server configurations.
pub struct ListModelServers {
registry: Arc<dyn ModelServerRegistry>,
}
impl ListModelServers {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
}
/// Lists configs.
///
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
Ok(ListModelServersOutput {
servers: self.registry.list().await?,
})
}
}
/// Input for [`SaveModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveModelServerInput {
/// Config to upsert by id.
pub config: LocalModelServerConfig,
}
/// Output of [`SaveModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveModelServerOutput {
/// Saved config.
pub config: LocalModelServerConfig,
}
/// Saves a local model-server configuration.
pub struct SaveModelServer {
registry: Arc<dyn ModelServerRegistry>,
}
impl SaveModelServer {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
}
/// Saves a config.
///
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(
&self,
input: SaveModelServerInput,
) -> Result<SaveModelServerOutput, AppError> {
self.registry.save(input.config.clone()).await?;
Ok(SaveModelServerOutput {
config: input.config,
})
}
}
/// Input for [`DeleteModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteModelServerInput {
/// Config id to delete.
pub server_id: LocalModelServerId,
}
/// Deletes a local model-server config when no OpenCode profile still references it.
pub struct DeleteModelServer {
registry: Arc<dyn ModelServerRegistry>,
profiles: Arc<dyn ProfileStore>,
}
impl DeleteModelServer {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>, profiles: Arc<dyn ProfileStore>) -> Self {
Self { registry, profiles }
}
/// Deletes a config after checking profile references.
///
/// # Errors
/// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced.
pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> {
let profiles = self.profiles.list().await?;
if profiles.iter().any(|profile| {
profile
.opencode
.as_ref()
.and_then(|config| config.local_model_server_id)
== Some(input.server_id)
}) {
return Err(ModelServerError::InUse(input.server_id.to_string()).into());
}
self.registry.delete(input.server_id).await?;
Ok(())
}
}
/// Input for [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerInput {
/// Referenced local model server.
pub server_id: LocalModelServerId,
}
/// Output of [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerOutput {
/// Ready server data to inject into OpenCode config.
pub ready: ModelServerReady,
}
/// Readiness retry policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadinessPolicy {
/// Number of probes after spawning.
pub attempts: usize,
/// Delay between attempts.
pub backoff: Duration,
}
impl Default for ReadinessPolicy {
fn default() -> Self {
Self {
attempts: 20,
backoff: Duration::from_millis(250),
}
}
}
#[derive(Debug, Clone)]
struct ActiveServer {
handle: ManagedProcessHandle,
port: u16,
stop_policy: StopPolicy,
}
/// Ensures a configured local model server is reachable, starting it when allowed.
pub struct EnsureLocalModelServer {
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
process: Arc<dyn ManagedProcess>,
runtime: Arc<dyn ModelServerRuntime>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
readiness: ReadinessPolicy,
}
impl EnsureLocalModelServer {
/// Builds the use case.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
process: Arc<dyn ManagedProcess>,
runtime: Arc<dyn ModelServerRuntime>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
registry,
probe,
process,
runtime,
fs,
events,
active: Mutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(),
}
}
/// Overrides readiness policy, mainly for tests.
#[must_use]
pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self {
self.readiness = readiness;
self
}
/// Ensures the server is reachable.
///
/// # Errors
/// [`AppError::ModelServer`] if the server cannot be prepared.
pub async fn execute(
&self,
input: EnsureLocalModelServerInput,
) -> Result<EnsureLocalModelServerOutput, AppError> {
let config = self
.registry
.get(&input.server_id)
.await?
.ok_or(ModelServerError::NotConfigured)?;
self.publish(config.id, ModelServerLifecycleStatus::Probing);
let initial_probe = match self.probe.probe(&config.endpoint).await {
Ok(status) => status,
Err(err) => return self.fail(config.id, err),
};
match initial_probe {
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: true },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyReused),
});
}
ModelServerStatus::Unreachable => {}
}
if !config.auto_start {
let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned());
self.publish_failure(config.id, &err);
return Err(err.into());
}
self.ensure_model_path_accessible(&config).await?;
self.ensure_no_active_port_collision(&config).await?;
self.publish(config.id, ModelServerLifecycleStatus::Starting);
let spec = match self.runtime.build_spawn_spec(&config) {
Ok(spec) => spec,
Err(err) => return self.fail(config.id, err),
};
let handle = match self.process.spawn(spec).await {
Ok(handle) => handle,
Err(err) => return self.fail(config.id, err),
};
self.active.lock().unwrap().insert(
config.id,
ActiveServer {
handle: handle.clone(),
port: config.endpoint.port,
stop_policy: config.stop_policy,
},
);
for attempt in 0..self.readiness.attempts {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, &handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {
if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
}
}
}
}
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, &handle).await;
self.fail(config.id, err)
}
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
///
/// # Errors
/// Returns the first process error after attempting every eligible stop.
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
.active
.lock()
.unwrap()
.iter()
.map(|(id, active)| (*id, active.clone()))
.collect();
let mut first_error: Option<ModelServerError> = None;
for (id, active) in entries {
if active.stop_policy != StopPolicy::StopOnAppExit {
continue;
}
if let Err(err) = self.process.kill(&active.handle).await {
if first_error.is_none() {
first_error = Some(err);
}
} else {
self.active.lock().unwrap().remove(&id);
}
}
if let Some(err) = first_error {
Err(err.into())
} else {
Ok(())
}
}
async fn ensure_model_path_accessible(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
let Some(path) = config.model.path.as_ref() else {
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
self.publish_failure(config.id, &err);
return Err(err.into());
};
match self
.fs
.exists(&RemotePath::new(path.as_str().to_owned()))
.await
{
Ok(true) => Ok(()),
Ok(false) => {
let err = ModelServerError::PathNotAccessible(path.as_str().to_owned());
self.publish_failure(config.id, &err);
Err(err.into())
}
Err(domain::ports::FsError::PermissionDenied(p)) => {
let err = ModelServerError::PermissionDenied(p);
self.publish_failure(config.id, &err);
Err(err.into())
}
Err(err) => {
let err = ModelServerError::PathNotAccessible(err.to_string());
self.publish_failure(config.id, &err);
Err(err.into())
}
}
}
async fn ensure_no_active_port_collision(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
let active: Vec<(LocalModelServerId, ActiveServer)> = self
.active
.lock()
.unwrap()
.iter()
.map(|(id, active)| (*id, active.clone()))
.collect();
for (id, active) in active {
if active.port != config.endpoint.port {
continue;
}
match self.process.status(&active.handle).await {
Ok(ProcessStatus::Running) => {
let err = ModelServerError::PortOccupied(config.endpoint.port);
self.publish_failure(config.id, &err);
return Err(err.into());
}
Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&id);
}
Err(err) => return self.fail(config.id, err),
}
}
Ok(())
}
async fn stop_started_server(
&self,
server_id: LocalModelServerId,
handle: &ManagedProcessHandle,
) {
let _ = self.process.kill(handle).await;
self.active.lock().unwrap().remove(&server_id);
}
fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) {
self.events
.publish(DomainEvent::ModelServerStatusChanged { server_id, status });
}
fn fail<T>(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result<T, AppError> {
self.publish_failure(server_id, &err);
Err(err.into())
}
fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) {
self.publish(
server_id,
ModelServerLifecycleStatus::Failed {
code: model_server_error_code(err).to_owned(),
message: err.to_string(),
},
);
}
}
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
ModelServerReady {
base_url: config.endpoint.base_url.clone(),
model: config.model.served_name.clone(),
status,
}
}
/// Stable model-server error code for event/DTO mapping.
#[must_use]
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
match err {
ModelServerError::NotConfigured => "not_configured",
ModelServerError::Invalid(_) => "invalid",
ModelServerError::PermissionDenied(_) => "permission_denied",
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
ModelServerError::PortOccupied(_) => "port_occupied",
ModelServerError::InUse(_) => "model_server_in_use",
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Timeout => "timeout",
}
}

View File

@ -0,0 +1,512 @@
//! Unit tests for the local model-server ensure use case.
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use application::{
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
ModelServerReadinessPolicy,
};
use domain::events::DomainEvent;
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint, ModelServerStatus, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::{LocalModelServerId, ProfileId, ProjectPath};
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn config(
id: LocalModelServerId,
port: u16,
path: &str,
auto_start: bool,
) -> LocalModelServerConfig {
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"llama.cpp",
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelPath::new(path).unwrap()),
"qwen3-coder-30b",
)
.unwrap(),
Some(ExecutablePath::new("llama-server").unwrap()),
Vec::new(),
auto_start,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[derive(Default)]
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
#[async_trait]
impl ModelServerRegistry for FakeRegistry {
async fn get(
&self,
id: &LocalModelServerId,
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
Ok(self.0.lock().unwrap().get(id).cloned())
}
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
Ok(self.0.lock().unwrap().values().cloned().collect())
}
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
self.0.lock().unwrap().insert(config.id, config);
Ok(())
}
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
self.0.lock().unwrap().remove(&id);
Ok(())
}
}
#[derive(Default)]
struct FakeProfiles(Mutex<Vec<AgentProfile>>);
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.0.lock().unwrap().push(profile.clone());
Ok(())
}
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
fn opencode_profile(id: u128, server_id: LocalModelServerId) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
"Local OpenCode",
"opencode",
Vec::new(),
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen", None, None)
.unwrap()
.with_local_model_server_id(server_id),
)
}
struct FakeProbe(Mutex<VecDeque<ModelServerStatus>>);
impl FakeProbe {
fn new(statuses: Vec<ModelServerStatus>) -> Self {
Self(Mutex::new(statuses.into()))
}
}
#[async_trait]
impl ModelServerProbe for FakeProbe {
async fn probe(
&self,
_endpoint: &ModelServerEndpoint,
) -> Result<ModelServerStatus, ModelServerError> {
Ok(self
.0
.lock()
.unwrap()
.pop_front()
.unwrap_or(ModelServerStatus::Unreachable))
}
}
#[derive(Default)]
struct FakeProcess {
spawns: Mutex<Vec<SpawnSpec>>,
kills: Mutex<Vec<String>>,
statuses: Mutex<HashMap<String, ProcessStatus>>,
}
#[async_trait]
impl ManagedProcess for FakeProcess {
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
self.spawns.lock().unwrap().push(spec);
let handle = ManagedProcessHandle {
id: format!("h{}", self.spawns.lock().unwrap().len()),
};
self.statuses
.lock()
.unwrap()
.insert(handle.id.clone(), ProcessStatus::Running);
Ok(handle)
}
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
self.kills.lock().unwrap().push(handle.id.clone());
Ok(())
}
async fn status(
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError> {
Ok(*self
.statuses
.lock()
.unwrap()
.get(&handle.id)
.unwrap_or(&ProcessStatus::Unknown))
}
}
struct FakeRuntime;
impl ModelServerRuntime for FakeRuntime {
fn build_spawn_spec(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError> {
Ok(SpawnSpec {
command: config
.binary
.as_ref()
.map(|binary| binary.as_str().to_owned())
.unwrap_or_else(|| "llama-server".to_owned()),
args: vec![
"--model".to_owned(),
config.model.path.as_ref().unwrap().as_str().to_owned(),
"--port".to_owned(),
config.endpoint.port.to_string(),
],
cwd: ProjectPath::new("/").unwrap(),
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
}
#[derive(Default)]
struct FakeFs {
existing: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, _path: &RemotePath) -> Result<Vec<u8>, FsError> {
Err(FsError::NotFound("unused".to_owned()))
}
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self
.existing
.lock()
.unwrap()
.iter()
.any(|p| p == path.as_str()))
}
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
#[derive(Default)]
struct FakeEvents(Mutex<Vec<DomainEvent>>);
impl EventBus for FakeEvents {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
fn ensure(
registry: Arc<FakeRegistry>,
probe: Arc<FakeProbe>,
process: Arc<FakeProcess>,
fs: Arc<FakeFs>,
events: Arc<FakeEvents>,
) -> EnsureLocalModelServer {
EnsureLocalModelServer::new(registry, probe, process, Arc::new(FakeRuntime), fs, events)
.with_readiness_policy(ModelServerReadinessPolicy {
attempts: 2,
backoff: Duration::ZERO,
})
}
#[tokio::test]
async fn reachable_server_is_reused_without_spawn() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(1), 8080, "/models/qwen.gguf", true))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(1) })
.await
.unwrap();
assert_eq!(out.ready.base_url, "http://localhost:8080/v1");
assert_eq!(out.ready.model, "qwen3-coder-30b");
assert!(process.spawns.lock().unwrap().is_empty());
}
#[tokio::test]
async fn absent_auto_start_spawns_and_waits_until_ready() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(2), 8081, "/models/qwen.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.push("/models/qwen.gguf".to_owned());
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyReused,
])),
Arc::clone(&process),
fs,
Arc::new(FakeEvents::default()),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(2) })
.await
.unwrap();
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
let spawns = process.spawns.lock().unwrap();
assert_eq!(spawns.len(), 1);
assert_eq!(spawns[0].command, "llama-server");
assert_eq!(
spawns[0].args,
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
);
}
#[tokio::test]
async fn missing_model_path_is_path_not_accessible() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(3), 8082, "/models/missing.gguf", true))
.await
.unwrap();
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(3) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
assert!(err.to_string().contains("path_not_accessible"));
}
#[tokio::test]
async fn active_managed_port_collision_is_explicit_error() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(4), 8083, "/models/a.gguf", true))
.await
.unwrap();
registry
.save(config(sid(5), 8083, "/models/b.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.extend(["/models/a.gguf".to_owned(), "/models/b.gguf".to_owned()]);
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyReused,
ModelServerStatus::Unreachable,
])),
Arc::clone(&process),
fs,
events,
);
usecase
.execute(EnsureLocalModelServerInput { server_id: sid(4) })
.await
.unwrap();
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(5) })
.await
.unwrap_err();
assert!(err.to_string().contains("port_occupied"));
assert_eq!(process.spawns.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn readiness_timeout_kills_started_process() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(6), 8084, "/models/qwen.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.push("/models/qwen.gguf".to_owned());
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
])),
Arc::clone(&process),
fs,
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(6) })
.await
.unwrap_err();
assert!(err.to_string().contains("timeout"));
assert_eq!(process.spawns.lock().unwrap().len(), 1);
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
}
#[tokio::test]
async fn missing_registry_entry_is_model_server_not_configured() {
let usecase = ensure(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(7) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
assert!(err.to_string().contains("not_configured"));
}
#[tokio::test]
async fn delete_model_server_refuses_when_profile_references_it() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(8), 8085, "/models/qwen.gguf", false))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(18, sid(8))])));
let usecase = DeleteModelServer::new(registry, profiles);
let err = usecase
.execute(DeleteModelServerInput { server_id: sid(8) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
}
#[tokio::test]
async fn delete_model_server_removes_unused_config() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(9), 8086, "/models/qwen.gguf", false))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles::default());
let usecase = DeleteModelServer::new(
Arc::clone(&registry) as Arc<dyn ModelServerRegistry>,
profiles,
);
usecase
.execute(DeleteModelServerInput { server_id: sid(9) })
.await
.unwrap();
assert!(registry.get(&sid(9)).await.unwrap().is_none());
}

View File

@ -13,15 +13,17 @@ use async_trait::async_trait;
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::ports::{ 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 domain::project::ProjectPath;
use application::{ use application::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, 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 { fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new( AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)), 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:?}"); 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 // ReferenceProfiles / catalogue
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -3,7 +3,8 @@
use crate::conversation::ConversationParty; use crate::conversation::ConversationParty;
use crate::ids::{ use crate::ids::{
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId, AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
TaskId, TemplateId,
}; };
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId; use crate::mailbox::TicketId;
@ -46,6 +47,24 @@ pub enum DomainEvent {
/// The session it runs in. /// The session it runs in.
session_id: SessionId, session_id: SessionId,
}, },
/// An agent launch failed before a runtime session could be created.
AgentLaunchFailed {
/// The agent whose launch failed.
agent_id: AgentId,
/// Failure cause namespace.
cause: String,
/// Stable error code.
code: String,
/// Human-readable message.
message: String,
},
/// A local model-server status changed while preparing a launch.
ModelServerStatusChanged {
/// The local model server.
server_id: LocalModelServerId,
/// New lifecycle status.
status: crate::model_server::ModelServerLifecycleStatus,
},
/// A first-class background task started running. /// A first-class background task started running.
BackgroundTaskStarted { BackgroundTaskStarted {
/// The owning project. /// The owning project.

View File

@ -68,6 +68,10 @@ typed_id!(
/// Identifies an [`crate::profile::AgentProfile`]. /// Identifies an [`crate::profile::AgentProfile`].
ProfileId ProfileId
); );
typed_id!(
/// Identifies a local model server configuration.
LocalModelServerId
);
typed_id!( typed_id!(
/// Identifies a [`crate::skill::Skill`]. /// Identifies a [`crate::skill::Skill`].
SkillId SkillId

View File

@ -49,6 +49,7 @@ pub mod mailbox;
pub mod markdown; pub mod markdown;
pub mod memory; pub mod memory;
pub mod memory_harvest; pub mod memory_harvest;
pub mod model_server;
pub mod orchestrator; pub mod orchestrator;
pub mod permission; pub mod permission;
pub mod ports; pub mod ports;
@ -72,8 +73,8 @@ mod validation;
pub use error::DomainError; pub use error::DomainError;
pub use ids::{ pub use ids::{
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
SprintId, TabId, TaskId, TemplateId, WindowId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
}; };
pub use project::{Project, ProjectPath}; pub use project::{Project, ProjectPath};
@ -153,6 +154,12 @@ pub use memory_harvest::{
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS, MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
}; };
pub use model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
StopPolicy,
};
pub use remote::{RemoteKind, RemoteRef, SshAuth}; pub use remote::{RemoteKind, RemoteRef, SshAuth};
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};

View File

@ -0,0 +1,368 @@
//! Local model-server configuration and status.
//!
//! These value objects describe a local OpenAI-compatible server that can back an
//! OpenCode profile. They are pure persisted configuration: probing, process
//! spawning and filesystem checks live behind ports.
use std::net::Ipv4Addr;
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::LocalModelServerId;
/// Local model server implementation family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LocalModelServerKind {
/// `llama-server` from llama.cpp.
LlamaCpp,
}
/// HTTP endpoint for an OpenAI-compatible model server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerEndpoint {
/// Normalised base URL, always ending in `/v1`.
#[serde(rename = "baseURL")]
pub base_url: String,
/// TCP port expected for the local server.
pub port: u16,
}
impl ModelServerEndpoint {
/// Builds a validated endpoint and normalises its base URL to `/v1`.
///
/// # Errors
/// Returns [`DomainError`] if the URL is blank, non-HTTP(S), or the port is 0.
pub fn new(base_url: impl Into<String>, port: u16) -> Result<Self, DomainError> {
let base_url = normalise_base_url(base_url.into())?;
if port == 0 {
return Err(DomainError::Invariant(
"modelServer.endpoint.port must be in 1..=65535".to_owned(),
));
}
Ok(Self { base_url, port })
}
}
fn normalise_base_url(raw: String) -> Result<String, DomainError> {
let trimmed = raw.trim().trim_end_matches('/').to_owned();
crate::validation::non_empty(&trimmed, "modelServer.endpoint.baseURL")?;
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return Err(DomainError::Invariant(
"modelServer.endpoint.baseURL must start with http:// or https://".to_owned(),
));
}
if !has_local_host_authority(&trimmed) {
return Err(DomainError::Invariant(
"modelServer.endpoint.baseURL must target localhost in V1".to_owned(),
));
}
if trimmed.ends_with("/v1") {
Ok(trimmed)
} else {
Ok(format!("{trimmed}/v1"))
}
}
fn has_local_host_authority(url: &str) -> bool {
let Some(authority_and_path) = url.split_once("://").map(|(_, rest)| rest) else {
return false;
};
let authority = authority_and_path.split('/').next().unwrap_or_default();
let host = authority
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(authority);
let host = host
.strip_prefix('[')
.and_then(|ipv6| ipv6.split_once(']').map(|(addr, _)| addr))
.unwrap_or_else(|| host.split(':').next().unwrap_or_default());
host.eq_ignore_ascii_case("localhost") || host == "::1" || is_ipv4_loopback(host)
}
fn is_ipv4_loopback(host: &str) -> bool {
host.parse::<Ipv4Addr>()
.map(|addr| addr.octets()[0] == 127)
.unwrap_or(false)
}
/// Absolute local path to a model file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ModelPath(pub String);
impl ModelPath {
/// Builds a validated absolute model path.
///
/// # Errors
/// Returns [`DomainError`] if the path is blank or relative.
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
let path = path.into();
crate::validation::non_empty(&path, "modelServer.model.path")?;
if !is_absolute_path(&path) {
return Err(DomainError::Invariant(
"modelServer.model.path must be absolute".to_owned(),
));
}
Ok(Self(path))
}
/// Returns the path as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Absolute local path to an executable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExecutablePath(pub String);
impl ExecutablePath {
/// Builds a validated executable path.
///
/// Absolute paths are accepted directly. Bare command names are accepted so
/// infrastructure can resolve them through `PATH` without shell interpolation.
///
/// # Errors
/// Returns [`DomainError`] if the path is blank.
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
let path = path.into();
crate::validation::non_empty(&path, "modelServer.binary")?;
Ok(Self(path))
}
/// Returns the path as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
fn is_absolute_path(path: &str) -> bool {
path.starts_with('/')
|| path.starts_with('\\')
|| (path.len() >= 3
&& path.as_bytes()[1] == b':'
&& path.as_bytes()[0].is_ascii_alphabetic()
&& matches!(path.as_bytes()[2], b'/' | b'\\'))
}
/// Model served by a local model server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalModelRef {
/// Stable model id in IdeA configuration.
pub id: String,
/// Human display label.
pub label: String,
/// Explicit absolute `.gguf` path. Required for auto-start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<ModelPath>,
/// Name exposed by the server and used in `OpenCodeConfig.model`.
pub served_name: String,
}
impl LocalModelRef {
/// Builds a validated local model reference.
///
/// # Errors
/// Returns [`DomainError`] if `id`, `label` or `served_name` is blank.
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
path: Option<ModelPath>,
served_name: impl Into<String>,
) -> Result<Self, DomainError> {
let id = id.into();
let label = label.into();
let served_name = served_name.into();
crate::validation::non_empty(&id, "modelServer.model.id")?;
crate::validation::non_empty(&label, "modelServer.model.label")?;
crate::validation::non_empty(&served_name, "modelServer.model.servedName")?;
Ok(Self {
id,
label,
path,
served_name,
})
}
}
/// Stop policy for a managed local model server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StopPolicy {
/// Leave the process running.
KeepAlive,
/// Stop when no profile uses it. Not used aggressively in V1.
StopWhenUnused,
/// Stop on application shutdown.
StopOnAppExit,
}
/// Persisted configuration for a local model server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalModelServerConfig {
/// Stable identity.
pub id: LocalModelServerId,
/// Server implementation family.
pub kind: LocalModelServerKind,
/// Display name.
pub name: String,
/// OpenAI-compatible endpoint.
pub endpoint: ModelServerEndpoint,
/// Served model.
pub model: LocalModelRef,
/// Optional executable path or bare command name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary: Option<ExecutablePath>,
/// Extra argv passed to the runtime.
#[serde(default)]
pub args: Vec<String>,
/// Whether IdeA may start the server lazily.
pub auto_start: bool,
/// Process stop policy.
#[serde(default = "default_stop_policy")]
pub stop_policy: StopPolicy,
}
fn default_stop_policy() -> StopPolicy {
StopPolicy::StopOnAppExit
}
impl LocalModelServerConfig {
/// Builds a validated local server configuration.
///
/// # Errors
/// Returns [`DomainError`] if names are blank or auto-start invariants are not
/// satisfied by static data.
#[allow(clippy::too_many_arguments)]
pub fn new(
id: LocalModelServerId,
kind: LocalModelServerKind,
name: impl Into<String>,
endpoint: ModelServerEndpoint,
model: LocalModelRef,
binary: Option<ExecutablePath>,
args: Vec<String>,
auto_start: bool,
stop_policy: StopPolicy,
) -> Result<Self, DomainError> {
let name = name.into();
crate::validation::non_empty(&name, "modelServer.name")?;
if auto_start {
if kind != LocalModelServerKind::LlamaCpp {
return Err(DomainError::Invariant(
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
));
}
if model.path.is_none() {
return Err(DomainError::Invariant(
"modelServer.autoStart requires model.path".to_owned(),
));
}
}
Ok(Self {
id,
kind,
name,
endpoint,
model,
binary,
args,
auto_start,
stop_policy,
})
}
}
/// Observed model-server status.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelServerStatus {
/// The server is reachable and was already running.
ReadyReused,
/// The server is reachable after IdeA started it.
ReadyStarted,
/// The server is not reachable.
Unreachable,
}
/// Successful ensure result used by agent launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServerReady {
/// Base URL to use in OpenCode config.
pub base_url: String,
/// Served model name to use in OpenCode config.
pub model: String,
/// Whether the server was reused or started.
pub status: ModelServerStatus,
}
/// Lifecycle status emitted for presentation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelServerLifecycleStatus {
/// No local model server is configured for the profile.
NotConfigured,
/// A readiness probe is in progress.
Probing,
/// IdeA is starting the server.
Starting,
/// The server is ready.
Ready {
/// `true` when an already-running server was reused.
reused: bool,
},
/// The server failed to become ready.
Failed {
/// Stable error code.
code: String,
/// Human-readable message.
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_normalises_to_v1() {
let endpoint = ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap();
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1/", 8080).unwrap();
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
}
#[test]
fn endpoint_rejects_remote_host() {
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
assert!(err.to_string().contains("localhost"));
let err =
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
assert!(err.to_string().contains("localhost"));
}
#[test]
fn auto_start_requires_model_path() {
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
let err = LocalModelServerConfig::new(
LocalModelServerId::from_uuid(uuid::Uuid::nil()),
LocalModelServerKind::LlamaCpp,
"llama.cpp",
endpoint,
model,
None,
Vec::new(),
true,
StopPolicy::StopOnAppExit,
)
.unwrap_err();
assert!(err.to_string().contains("model.path"));
}
}

View File

@ -34,12 +34,15 @@ use crate::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
}; };
use crate::events::DomainEvent; use crate::events::DomainEvent;
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId}; use crate::ids::{
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
};
use crate::issue::{ use crate::issue::{
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
}; };
use crate::markdown::MarkdownDoc; use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
use crate::permission::ProjectPermissions; use crate::permission::ProjectPermissions;
use crate::profile::{AgentProfile, EmbedderProfile}; use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath}; use crate::project::{Project, ProjectPath};
@ -206,6 +209,27 @@ pub struct Output {
pub stderr: Vec<u8>, pub stderr: Vec<u8>,
} }
/// Opaque handle for a managed local process.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ManagedProcessHandle {
/// Stable local process identity as assigned by the adapter.
pub id: String,
}
/// Observed status of a managed local process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessStatus {
/// Process is still running.
Running,
/// Process has exited.
Exited {
/// Exit code, when available.
code: Option<i32>,
},
/// Handle is unknown to the adapter.
Unknown,
}
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL). /// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemotePath(pub String); pub struct RemotePath(pub String);
@ -502,6 +526,41 @@ pub enum ProcessError {
Io(String), Io(String),
} }
/// Errors from local model-server ports.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ModelServerError {
/// The referenced configuration does not exist.
#[error("not configured")]
NotConfigured,
/// Server or configuration is invalid.
#[error("invalid: {0}")]
Invalid(String),
/// Filesystem permission denied.
#[error("permission denied: {0}")]
PermissionDenied(String),
/// Required path is not accessible.
#[error("path not accessible: {0}")]
PathNotAccessible(String),
/// The configured port is already in use by another managed server.
#[error("port occupied: {0}")]
PortOccupied(u16),
/// The server is still referenced by another configuration object.
#[error("model server in use: {0}")]
InUse(String),
/// Readiness probe failed unexpectedly.
#[error("probe failed: {0}")]
Probe(String),
/// Process operation failed.
#[error("process failed: {0}")]
Process(String),
/// Store operation failed.
#[error("store failed: {0}")]
Store(String),
/// Readiness timed out.
#[error("readiness timed out")]
Timeout,
}
/// Errors from [`FileSystem`]. /// Errors from [`FileSystem`].
#[derive(Debug, Clone, PartialEq, Eq, Error)] #[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum FsError { pub enum FsError {
@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>; async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
} }
/// Probe readiness of an OpenAI-compatible model server.
#[async_trait]
pub trait ModelServerProbe: Send + Sync {
/// Probes `endpoint` and returns the observed status.
///
/// # Errors
/// [`ModelServerError`] on unexpected probe failure. An unreachable server
/// should normally return [`ModelServerStatus::Unreachable`].
async fn probe(
&self,
endpoint: &ModelServerEndpoint,
) -> Result<ModelServerStatus, ModelServerError>;
}
/// Manages local long-lived child processes.
#[async_trait]
pub trait ManagedProcess: Send + Sync {
/// Spawns a process from an argv-structured spec.
///
/// # Errors
/// [`ModelServerError`] on spawn failure.
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;
/// Kills a process.
///
/// # Errors
/// [`ModelServerError`] on kill failure.
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;
/// Returns the status of a process.
///
/// # Errors
/// [`ModelServerError`] on status failure.
async fn status(
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError>;
}
/// Builds the argv-structured spawn spec for a local model server.
pub trait ModelServerRuntime: Send + Sync {
/// Builds a spawn spec from a validated local-server config.
///
/// # Errors
/// [`ModelServerError`] if the config cannot be launched.
fn build_spawn_spec(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError>;
}
/// Persists local model-server configuration in the global IdeA store.
#[async_trait]
pub trait ModelServerRegistry: Send + Sync {
/// Returns one server config by id.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn get(
&self,
id: &LocalModelServerId,
) -> Result<Option<LocalModelServerConfig>, ModelServerError>;
/// Lists all server configs.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;
/// Saves one server config.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;
/// Deletes one server config.
///
/// # Errors
/// [`ModelServerError`] on store failure.
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
}
/// Location-neutral filesystem access. /// Location-neutral filesystem access.
#[async_trait] #[async_trait]
pub trait FileSystem: Send + Sync { pub trait FileSystem: Send + Sync {

View File

@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::DomainError; use crate::error::DomainError;
use crate::ids::ProfileId; use crate::ids::{LocalModelServerId, ProfileId};
use crate::permission::ProjectorKey; use crate::permission::ProjectorKey;
/// Strategy for injecting an agent's `.md` context into the launched CLI. /// Strategy for injecting an agent's `.md` context into the launched CLI.
@ -295,6 +295,11 @@ pub struct OpenCodeConfig {
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`. /// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<bool>, pub attachment: Option<bool>,
/// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA.
///
/// `None` garde le comportement existant : profil OpenCode manuel/externe.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_model_server_id: Option<LocalModelServerId>,
} }
impl OpenCodeConfig { impl OpenCodeConfig {
@ -325,9 +330,17 @@ impl OpenCodeConfig {
model, model,
reasoning, reasoning,
attachment, attachment,
local_model_server_id: None,
}) })
} }
/// Builder : associe cette configuration à un serveur local managé.
#[must_use]
pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self {
self.local_model_server_id = Some(id);
self
}
/// Valeur effective de `reasoning` quand le profil ne la porte pas. /// Valeur effective de `reasoning` quand le profil ne la porte pas.
#[must_use] #[must_use]
pub fn reasoning_enabled(&self) -> bool { pub fn reasoning_enabled(&self) -> bool {
@ -1107,7 +1120,7 @@ impl AgentProfile {
#[cfg(test)] #[cfg(test)]
mod mcp_tests { mod mcp_tests {
use super::*; use super::*;
use crate::ids::ProfileId; use crate::ids::{LocalModelServerId, ProfileId};
/// A reference profile without any MCP capability (the historical shape). /// A reference profile without any MCP capability (the historical shape).
fn profile_without_mcp() -> AgentProfile { fn profile_without_mcp() -> AgentProfile {
@ -1225,6 +1238,46 @@ mod mcp_tests {
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err()); assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
} }
#[test]
fn opencode_config_omits_local_model_server_id_when_absent() {
let config = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap();
let json = serde_json::to_string(&config).expect("serialise");
assert!(
!json.contains("localModelServerId"),
"manual external OpenCode profiles must keep the legacy JSON shape: {json}"
);
let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.local_model_server_id, None);
}
#[test]
fn opencode_config_serialises_local_model_server_id_camelcase() {
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
let config = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap()
.with_local_model_server_id(server_id);
let value = serde_json::to_value(&config).expect("serialise");
assert_eq!(value["localModelServerId"], server_id.to_string());
assert!(value.get("local_model_server_id").is_none());
let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise");
assert_eq!(back.local_model_server_id, Some(server_id));
}
#[test] #[test]
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() { fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
let profile = profile_without_mcp() let profile = profile_without_mcp()

View File

@ -26,6 +26,7 @@ pub mod input;
pub mod inspector; pub mod inspector;
pub mod issues; pub mod issues;
pub mod mailbox; pub mod mailbox;
pub mod model_server;
pub mod orchestrator; pub mod orchestrator;
pub mod permission; pub mod permission;
pub mod process; pub mod process;
@ -64,6 +65,9 @@ pub use inspector::{
}; };
pub use issues::{FsIssueNumberAllocator, FsIssueStore}; pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox; pub use mailbox::InMemoryMailbox;
pub use model_server::{
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
};
pub use orchestrator::mcp::{ pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider, McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
ToolPolicyRegistry, ToolPolicyRegistry,

View File

@ -0,0 +1,315 @@
//! Infrastructure adapters for local model servers.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::process::{Child, Command};
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
use domain::ports::{
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
};
use domain::{LocalModelServerId, ProjectPath};
/// HTTP readiness probe for OpenAI-compatible servers.
#[derive(Clone)]
pub struct HttpOpenAiCompatibleProbe {
client: reqwest::Client,
}
impl Default for HttpOpenAiCompatibleProbe {
fn default() -> Self {
Self::new(Duration::from_secs(2))
}
}
impl HttpOpenAiCompatibleProbe {
/// Builds the probe with a request timeout.
#[must_use]
pub fn new(timeout: Duration) -> Self {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("reqwest client with timeout builds");
Self { client }
}
}
#[async_trait]
impl ModelServerProbe for HttpOpenAiCompatibleProbe {
async fn probe(
&self,
endpoint: &ModelServerEndpoint,
) -> Result<domain::ModelServerStatus, ModelServerError> {
let models = format!("{}/models", endpoint.base_url.trim_end_matches('/'));
if is_ready(self.client.get(models).send().await) {
return Ok(domain::ModelServerStatus::ReadyReused);
}
let root = endpoint.base_url.trim_end_matches("/v1").to_owned();
if is_ready(self.client.get(root).send().await) {
return Ok(domain::ModelServerStatus::ReadyReused);
}
Ok(domain::ModelServerStatus::Unreachable)
}
}
fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
result
.map(|response| response.status().is_success())
.unwrap_or(false)
}
/// Builds `llama-server` argv without shell interpolation.
#[derive(Debug, Default, Clone, Copy)]
pub struct LlamaCppRuntime;
impl LlamaCppRuntime {
/// Creates the runtime.
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl ModelServerRuntime for LlamaCppRuntime {
fn build_spawn_spec(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError> {
if config.kind != LocalModelServerKind::LlamaCpp {
return Err(ModelServerError::Invalid(
"only LlamaCpp local model servers are supported".to_owned(),
));
}
let model_path =
config.model.path.as_ref().ok_or_else(|| {
ModelServerError::PathNotAccessible("model.path missing".to_owned())
})?;
let command = resolve_binary(
config
.binary
.as_ref()
.map(|binary| binary.as_str())
.unwrap_or("llama-server"),
)?;
let mut args = vec![
"--model".to_owned(),
model_path.as_str().to_owned(),
"--port".to_owned(),
config.endpoint.port.to_string(),
];
args.extend(config.args.clone());
Ok(SpawnSpec {
command,
args,
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
}
fn resolve_binary(raw: &str) -> Result<String, ModelServerError> {
if is_path_like(raw) {
let path = Path::new(raw);
if path.is_file() {
return Ok(raw.to_owned());
}
return Err(ModelServerError::PathNotAccessible(raw.to_owned()));
}
if let Some(path) = find_in_path(raw) {
return Ok(path.to_string_lossy().into_owned());
}
Err(ModelServerError::PathNotAccessible(format!(
"{raw} not found in PATH"
)))
}
fn is_path_like(raw: &str) -> bool {
raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute()
}
fn find_in_path(command: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(command))
.find(|candidate| candidate.is_file())
})
}
/// Local child-process manager for long-lived model servers.
#[derive(Default)]
pub struct LocalManagedProcess {
children: Mutex<HashMap<String, Child>>,
}
impl LocalManagedProcess {
/// Creates an empty process manager.
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl ManagedProcess for LocalManagedProcess {
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
let mut command = Command::new(&spec.command);
command.args(&spec.args);
if spec.cwd.as_str() != "/" {
command.current_dir(spec.cwd.as_str());
}
for (key, value) in &spec.env {
command.env(key, value);
}
let child = command
.spawn()
.map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?;
let id = uuid::Uuid::new_v4().to_string();
self.children.lock().unwrap().insert(id.clone(), child);
Ok(ManagedProcessHandle { id })
}
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else {
return Ok(());
};
child
.start_kill()
.map_err(|e| ModelServerError::Process(e.to_string()))
}
async fn status(
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError> {
let mut children = self.children.lock().unwrap();
let Some(child) = children.get_mut(&handle.id) else {
return Ok(ProcessStatus::Unknown);
};
match child
.try_wait()
.map_err(|e| ModelServerError::Process(e.to_string()))?
{
Some(status) => Ok(ProcessStatus::Exited {
code: status.code(),
}),
None => Ok(ProcessStatus::Running),
}
}
}
/// File name of the global local-model-server registry.
const MODEL_SERVERS_FILE: &str = "model-servers.json";
const MODEL_SERVERS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelServersDoc {
version: u32,
servers: Vec<LocalModelServerConfig>,
}
impl Default for ModelServersDoc {
fn default() -> Self {
Self {
version: MODEL_SERVERS_VERSION,
servers: Vec::new(),
}
}
}
/// Filesystem-backed global model-server registry.
#[derive(Clone)]
pub struct FsModelServerRegistry {
fs: std::sync::Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsModelServerRegistry {
/// Builds the registry from a filesystem and global app-data dir.
#[must_use]
pub fn new(fs: std::sync::Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn path(&self) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}"))
}
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
Err(domain::ports::FsError::PermissionDenied(p)) => {
Err(ModelServerError::PermissionDenied(p))
}
Err(e) => Err(ModelServerError::Store(e.to_string())),
}
}
async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs.create_dir_all(&dir).await.map_err(|e| match e {
domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p),
other => ModelServerError::Store(other.to_string()),
})?;
let bytes = serde_json::to_vec_pretty(doc)
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?;
self.fs
.write(&self.path(), &bytes)
.await
.map_err(|e| match e {
domain::ports::FsError::PermissionDenied(p) => {
ModelServerError::PermissionDenied(p)
}
other => ModelServerError::Store(other.to_string()),
})
}
}
#[async_trait]
impl ModelServerRegistry for FsModelServerRegistry {
async fn get(
&self,
id: &LocalModelServerId,
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
Ok(self
.read_doc()
.await?
.servers
.into_iter()
.find(|server| &server.id == id))
}
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
Ok(self.read_doc().await?.servers)
}
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
let mut doc = self.read_doc().await?;
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
*slot = config;
} else {
doc.servers.push(config);
}
self.write_doc(&doc).await
}
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
let mut doc = self.read_doc().await?;
doc.servers.retain(|server| server.id != id);
self.write_doc(&doc).await
}
}

View File

@ -0,0 +1,111 @@
//! Infrastructure tests for local model-server adapters.
use std::path::PathBuf;
use std::sync::Arc;
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint, StopPolicy,
};
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
use domain::LocalModelServerId;
use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("idea-model-server-{}", Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(Uuid::from_u128(n))
}
fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
let binary = std::env::current_exe()
.unwrap()
.to_string_lossy()
.into_owned();
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"Local Qwen",
ModelServerEndpoint::new(format!("http://localhost:{port}/v1"), port).unwrap(),
LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
"qwen3-coder-30b",
)
.unwrap(),
Some(ExecutablePath::new(binary).unwrap()),
vec!["--ctx-size".to_owned(), "8192".to_owned()],
true,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[test]
fn llamacpp_runtime_builds_structured_argv() {
let spec = LlamaCppRuntime::new()
.build_spawn_spec(&config(sid(1), 8080))
.unwrap();
assert_eq!(
spec.command,
std::env::current_exe().unwrap().to_string_lossy()
);
assert_eq!(
spec.args,
vec![
"--model",
"/models/qwen.gguf",
"--port",
"8080",
"--ctx-size",
"8192"
]
);
assert!(spec.context_plan.is_none());
}
#[tokio::test]
async fn fs_model_server_registry_roundtrips_global_json() {
let tmp = TempDir::new();
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
let first = config(sid(2), 8081);
registry.save(first.clone()).await.unwrap();
assert_eq!(registry.get(&first.id).await.unwrap(), Some(first.clone()));
assert_eq!(registry.list().await.unwrap(), vec![first.clone()]);
assert!(
fs.exists(&tmp.child("model-servers.json")).await.unwrap(),
"registry must live in global app-data dir"
);
let updated = config(sid(2), 8082);
registry.save(updated.clone()).await.unwrap();
assert_eq!(registry.list().await.unwrap(), vec![updated]);
}