diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0954d44..f884996 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -34,28 +34,30 @@ use crate::dto::{ parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, - GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, - HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, + CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, - SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, - UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, - UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, + StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, + SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, + TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + WriteTerminalRequestDto, parse_model_server_id, save_model_server_input, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -793,6 +795,25 @@ pub async fn save_profile( .map_err(ErrorDto::from) } +/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance +/// from the canonical `opencode-llamacpp` seed/template. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a +/// blank requested name). +#[tauri::command] +pub async fn clone_opencode_profile_from_seed( + request: CloneOpenCodeProfileFromSeedRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .clone_opencode_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + /// `delete_profile` — delete a profile by id. /// /// # Errors @@ -836,6 +857,73 @@ pub async fn configure_profiles( .map_err(ErrorDto::from) } +/// `list_model_servers` — list configured local model servers. +/// +/// # Errors +/// Returns an [`ErrorDto`] on registry failure. +#[tauri::command] +pub async fn list_model_servers( + state: State<'_, AppState>, +) -> Result { + 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 { + 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) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 0bb2aea..4f027d6 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto { // --------------------------------------------------------------------------- use application::{ - ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, - DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, - ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, + FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, + SaveProfileInput, SaveProfileOutput, }; -use domain::profile::AgentProfile; +use domain::profile::{AgentProfile, OpenCodeConfig}; use domain::ProfileId; /// A profile crossing the wire. [`AgentProfile`] already serialises camelCase @@ -776,6 +777,12 @@ impl From for ProfileDto { } } +impl From for ProfileDto { + fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + /// Request DTO for `detect_profiles`: the candidate profiles to probe. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -838,6 +845,27 @@ impl From 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, + /// Optional OpenCode config override. When omitted, the seed config is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, +} + +impl From for CloneOpenCodeProfileFromSeedInput { + fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { + Self { + name: dto.name, + opencode: dto.opencode, + } + } +} + /// Request DTO for `configure_profiles` (closes the first run). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -879,6 +907,252 @@ impl From 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 for ModelServerKindDto { + fn from(kind: LocalModelServerKind) -> Self { + match kind { + LocalModelServerKind::LlamaCpp => Self::LlamaCpp, + } + } +} + +impl From 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 for StopPolicyDto { + fn from(policy: StopPolicy) -> Self { + match policy { + StopPolicy::KeepAlive => Self::KeepAlive, + StopPolicy::StopWhenUnused => Self::StopWhenUnused, + StopPolicy::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +impl From 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, + /// 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, + /// Extra argv entries. + #[serde(default)] + pub args: Vec, + /// 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 { + 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); + +impl From 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 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 { + 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 { + Ok(SaveModelServerInput { + config: request.config.into_domain(existing)?, + }) +} + +fn optional_non_empty(raw: Option) -> Option { + 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) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 3d5f43d..6a33d92 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter}; use domain::conversation::ConversationParty; use domain::events::{DomainEvent, OrchestrationSource}; use domain::input::AgentLiveness; +use domain::model_server::ModelServerLifecycleStatus; use domain::{IssueLinkKind, IssuePriority, IssueStatus}; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. pub const DOMAIN_EVENT: &str = "domain://event"; +/// Dedicated Tauri event for local model-server status changes. +pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; +/// Dedicated Tauri event for launch failures. +pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; + +/// Model-server lifecycle status on the Tauri wire. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum ModelServerStatusDto { + /// No server is configured for this profile. + NotConfigured, + /// Readiness probing is in progress. + Probing, + /// IdeA is starting the process. + Starting, + /// Server is ready. + #[serde(rename_all = "camelCase")] + Ready { + /// `true` when an existing server was reused. + reused: bool, + }, + /// Server preparation failed. + #[serde(rename_all = "camelCase")] + Failed { + /// Stable failure code. + code: String, + /// Human-readable message. + message: String, + }, +} + +impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { + fn from(status: &ModelServerLifecycleStatus) -> Self { + match status { + ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, + ModelServerLifecycleStatus::Probing => Self::Probing, + ModelServerLifecycleStatus::Starting => Self::Starting, + ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, + ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { + code: code.clone(), + message: message.clone(), + }, + } + } +} + +/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerStatusChangedDto { + /// Local model server id. + pub server_id: String, + /// Lifecycle status. + pub status: ModelServerStatusDto, +} + +/// Payload for [`AGENT_LAUNCH_FAILED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLaunchFailedDto { + /// Agent id. + pub agent_id: String, + /// Failure cause namespace. + pub cause: String, + /// Stable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} /// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged). /// @@ -42,6 +112,26 @@ pub enum DomainEventDto { /// Session id. session_id: String, }, + /// An agent launch failed before runtime session creation. + #[serde(rename_all = "camelCase")] + AgentLaunchFailed { + /// Agent id. + agent_id: String, + /// Failure cause namespace. + cause: String, + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, + /// Local model-server status changed. + #[serde(rename_all = "camelCase")] + ModelServerStatusChanged { + /// Local model server id. + server_id: String, + /// Lifecycle status. + status: ModelServerStatusDto, + }, /// An agent exited. #[serde(rename_all = "camelCase")] AgentExited { @@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), session_id: session_id.to_string(), }, + DomainEvent::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => Self::AgentLaunchFailed { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + DomainEvent::ModelServerStatusChanged { server_id, status } => { + Self::ModelServerStatusChanged { + server_id: server_id.to_string(), + status: status.into(), + } + } DomainEvent::AgentExited { agent_id, code } => Self::AgentExited { agent_id: agent_id.to_string(), code: *code, @@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { } let dto = DomainEventDto::from(&event); let _ = app.emit(DOMAIN_EVENT, dto); + match &event { + DomainEvent::ModelServerStatusChanged { server_id, status } => { + let _ = app.emit( + MODEL_SERVER_STATUS_CHANGED, + ModelServerStatusChangedDto { + server_id: server_id.to_string(), + status: status.into(), + }, + ); + } + DomainEvent::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => { + let _ = app.emit( + AGENT_LAUNCH_FAILED, + AgentLaunchFailedDto { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + ); + } + _ => {} + } } // The bus dropped some events for this slow receiver; keep going. Err(RecvError::Lagged(_)) => continue, @@ -1008,13 +1143,47 @@ mod tests { use super::*; use domain::ids::AgentId; use domain::mailbox::TicketId; - use domain::ProjectId; + use domain::{LocalModelServerId, ProjectId}; use serde_json::json; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn server(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn model_server_status_changed_relays_ready_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(35), + status: ModelServerLifecycleStatus::Ready { reused: true }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(35).to_string()); + assert_eq!(json["status"]["state"], "ready"); + assert_eq!(json["status"]["reused"], true); + } + + #[test] + fn agent_launch_failed_relays_model_server_cause() { + let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { + agent_id: agent(36), + cause: "model_server".to_owned(), + code: "path_not_accessible".to_owned(), + message: "path not accessible: /models/missing.gguf".to_owned(), + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLaunchFailed"); + assert_eq!(json["agentId"], agent(36).to_string()); + assert_eq!(json["cause"], "model_server"); + assert_eq!(json["code"], "path_not_accessible"); + } + /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que /// le front badge). Garantit le câblage présentation de la détection de stall. diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index a578e40..fca719d 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -106,6 +106,8 @@ pub fn run() { // If we killed first, the registry would be empty and every // agent would be persisted as "closed". let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); + let model_servers = + std::sync::Arc::clone(&state.ensure_local_model_server); let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { @@ -119,6 +121,7 @@ pub fn run() { for h in handles { let _ = pty.kill(&h).await; } + let _ = model_servers.stop_on_app_exit().await; }); } } @@ -156,8 +159,12 @@ pub fn run() { commands::detect_profiles, commands::list_profiles, commands::save_profile, + commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, + commands::list_model_servers, + commands::save_model_server, + commands::delete_model_server, commands::list_embedder_profiles, commands::save_embedder_profile, commands::delete_embedder_profile, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 2bd8e17..1ba18fe 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -14,32 +14,32 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, - CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, - CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, - CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, - DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, - DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues, - ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, - ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, - RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, + CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, + CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, + CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, + DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, + DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, + ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, + SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, + UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -69,10 +69,11 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, - FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository, - HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, + FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, + Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem, + LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, @@ -776,6 +777,8 @@ pub struct AppState { pub list_profiles: Arc, /// Save (upsert) a profile. pub save_profile: Arc, + /// Create a new OpenCode profile instance from the canonical seed. + pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. pub delete_profile: Arc, /// Persist the batch of chosen profiles (closes the first run). @@ -784,6 +787,14 @@ pub struct AppState { pub reference_profiles: Arc, /// Whether the first-run wizard should show + the reference catalogue. pub first_run_state: Arc, + /// Ensures local llama.cpp model servers for OpenCode profiles. + pub ensure_local_model_server: Arc, + /// Lists local model server configurations. + pub list_model_servers: Arc, + /// Saves local model server configurations. + pub save_model_server: Arc, + /// Deletes local model server configurations when unused. + pub delete_model_server: Arc, /// The local PTY adapter, kept port-typed so the presentation layer can /// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport). pub pty_port: Arc, @@ -1183,11 +1194,41 @@ impl AppState { let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port))); + let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port))); let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port))); let reference_profiles = Arc::new(ReferenceProfiles::new()); let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port))); + let model_server_registry = Arc::new(FsModelServerRegistry::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new( + Arc::clone(&model_server_registry) as Arc, + Arc::new(HttpOpenAiCompatibleProbe::default()) + as Arc, + Arc::new(LocalManagedProcess::new()) as Arc, + Arc::new(LlamaCppRuntime::new()) as Arc, + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + let model_server_registry_port = + Arc::clone(&model_server_registry) as Arc; + let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( + &model_server_registry_port, + ))); + let save_model_server = Arc::new(SaveModelServer::new(Arc::clone( + &model_server_registry_port, + ))); + let delete_model_server = Arc::new(DeleteModelServer::new( + Arc::clone(&model_server_registry_port), + Arc::clone(&profile_store_port), + )); + let pty_bridge = Arc::new(PtyBridge::new()); // Twin of the PTY bridge for structured chat sessions (§17.7): routes a // turn's ReplyChunks to the owning chat cell and retains the conversation @@ -1499,7 +1540,8 @@ impl AppState { // parse ⇒ section omise, jamais d'échec de lancement. .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, - }) as Arc), + }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)), ); // Inter-agent launcher: same context, memory, permissions and live-state @@ -1530,6 +1572,7 @@ impl AppState { .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)) .with_structured( Arc::clone(&session_factory), Arc::clone(&structured_sessions), @@ -2196,10 +2239,15 @@ impl AppState { detect_profiles, list_profiles, save_profile, + clone_opencode_profile_from_seed, delete_profile, configure_profiles, reference_profiles, first_run_state, + ensure_local_model_server, + list_model_servers, + save_model_server, + delete_model_server, pty_port, terminal_sessions, event_bus, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs new file mode 100644 index 0000000..81dc85e --- /dev/null +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -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"); +} diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index b7fa914..1615d28 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -3,15 +3,16 @@ //! error behaviour. use app_tauri_lib::dto::{ - parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto, - DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, + parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto, + ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, }; use application::{ - ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, - ProfileAvailability, SaveProfileInput, + CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput, + DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput, }; -use domain::ids::ProfileId; -use domain::profile::{AgentProfile, ContextInjection}; +use domain::ids::{LocalModelServerId, ProfileId}; +use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig}; use serde_json::json; use uuid::Uuid; @@ -94,6 +95,47 @@ fn save_request_deserialises_profile() { 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] fn configure_request_deserialises_profiles() { let raw = json!({ "profiles": [] }); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 4f1ff5e..c30c908 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -35,6 +35,7 @@ use domain::live_state::WorkStatus; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; +use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput}; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; use crate::workstate::GetLiveStateLean; @@ -1136,6 +1137,10 @@ pub struct LaunchAgent { /// injection (zéro régression). Best-effort strict : provider absent / erreur / /// parse ⇒ section omise, jamais d'échec de lancement. live_state_lean: Option>, + /// 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>, } impl LaunchAgent { @@ -1174,9 +1179,17 @@ impl LaunchAgent { provider_sessions: None, projectors: None, live_state_lean: None, + local_model_server: None, } } + /// Injects the local model-server ensure use case used by OpenCode profiles. + #[must_use] + pub fn with_local_model_server(mut self, ensure: Arc) -> Self { + self.local_model_server = Some(ensure); + self + } + /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu /// `# État du projet` (status + intent des autres agents) est injecté dans le /// convention file. Sans cet appel (cas legacy / tests existants), aucune section @@ -1550,7 +1563,7 @@ impl LaunchAgent { .contexts .read_context(&input.project, &agent.id) .await?; - let profile = self + let mut profile = self .profiles .list() .await? @@ -1649,6 +1662,9 @@ impl LaunchAgent { ) .await?; + self.ensure_local_model_server_for_opencode(&agent, &mut profile) + .await?; + // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ── // Strictement APRÈS le convention file (étape 5) et AVANT le spawn / // `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`, @@ -2336,6 +2352,60 @@ impl LaunchAgent { } } } + + async fn ensure_local_model_server_for_opencode( + &self, + agent: &Agent, + profile: &mut AgentProfile, + ) -> Result<(), AppError> { + if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { + return Ok(()); + } + let Some(opencode) = profile.opencode.as_mut() else { + return Ok(()); + }; + let Some(server_id) = opencode.local_model_server_id else { + return Ok(()); + }; + let Some(ensure) = self.local_model_server.as_ref() else { + let err = AppError::ModelServer { + code: "not_wired".to_owned(), + message: + "OpenCode profile references localModelServerId but no model server service is wired" + .to_owned(), + }; + self.publish_agent_launch_failed(agent.id, &err); + return Err(err); + }; + + match ensure + .execute(EnsureLocalModelServerInput { server_id }) + .await + { + Ok(output) => { + opencode.base_url = output.ready.base_url; + opencode.model = output.ready.model; + Ok(()) + } + Err(err) => { + self.publish_agent_launch_failed(agent.id, &err); + Err(err) + } + } + } + + fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) { + let (cause, code) = match err { + AppError::ModelServer { code, .. } => ("model_server", code.as_str()), + _ => ("launch", err.code()), + }; + self.events.publish(DomainEvent::AgentLaunchFailed { + agent_id, + cause: cause.to_owned(), + code: code.to_owned(), + message: err.to_string(), + }); + } } /// Outcome of the R0a discrimination between a legitimate **view reattach** and a diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 8ba986d..7a11e5b 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -41,8 +41,10 @@ pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, }; pub use usecases::{ - ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, - FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles, - ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles, + ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput, + SaveProfile, SaveProfileInput, SaveProfileOutput, }; diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 3ce0d03..403d1b0 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -13,12 +13,13 @@ use std::sync::Arc; -use domain::ports::{AgentRuntime, ProfileStore}; -use domain::profile::AgentProfile; +use domain::ids::ProfileId; +use domain::ports::{AgentRuntime, IdGenerator, ProfileStore}; +use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter}; use crate::error::AppError; -use super::catalogue::selectable_reference_profiles; +use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; // --------------------------------------------------------------------------- // DetectProfiles @@ -133,6 +134,111 @@ pub struct SaveProfileOutput { pub profile: AgentProfile, } +// --------------------------------------------------------------------------- +// CloneOpenCodeProfileFromSeed +// --------------------------------------------------------------------------- + +/// Input for [`CloneOpenCodeProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneOpenCodeProfileFromSeedInput { + /// Optional display name for the cloned profile. When absent, a copy label is + /// derived from the seed name. + pub name: Option, + /// Optional OpenCode config override. When absent, the seed config is copied. + pub opencode: Option, +} + +/// 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, + ids: Arc, +} + +impl CloneOpenCodeProfileFromSeed { + /// Builds the use case from the profile store and id generator ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> 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 { + 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 { + for _ in 0..16 { + let id = ProfileId::from_uuid(ids.new_uuid()); + if existing.iter().all(|profile| profile.id != id) { + return Ok(id); + } + } + Err(AppError::Internal( + "could not allocate a unique profile id".into(), + )) +} + /// Persists (creates or replaces) a single profile. pub struct SaveProfile { store: Arc, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 07a3be3..f4fae74 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -6,8 +6,8 @@ //! with one error shape when building its `ErrorDTO`. use domain::ports::{ - AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, - RemoteError, RuntimeError, StoreError, + AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError, + ProcessError, PtyError, RemoteError, RuntimeError, StoreError, }; use domain::{AgentId, NodeId}; use domain::{IssueStoreError, SprintStoreError}; @@ -38,6 +38,15 @@ pub enum AppError { #[error("process error: {0}")] 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. #[error("git error: {0}")] Git(String), @@ -101,6 +110,7 @@ impl AppError { Self::FileSystem(_) => "FILESYSTEM", Self::Store(_) => "STORE", Self::Process(_) => "PROCESS", + Self::ModelServer { .. } => "MODEL_SERVER", Self::Git(_) => "GIT", Self::Remote(_) => "REMOTE", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", @@ -183,6 +193,30 @@ impl From for AppError { } } +impl From 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 for AppError { fn from(e: RuntimeError) -> Self { Self::Process(e.to_string()) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 1719ba5..2b36d12 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -22,6 +22,7 @@ pub mod health; pub mod issues; pub mod layout; pub mod memory; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod project; @@ -39,19 +40,20 @@ pub use agent::{ drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, - ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, - CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, - FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, - InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, - LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles, - ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, - LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, - ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, - SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, - LIVE_STATE_INJECT_MAX, RESUME_PROMPT, + CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, + DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, + InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, + ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, + ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, + StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, @@ -102,6 +104,12 @@ pub use memory::{ RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, 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::{ resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs new file mode 100644 index 0000000..4b3958c --- /dev/null +++ b/crates/application/src/model_server.rs @@ -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, +} + +/// Lists local model-server configurations. +pub struct ListModelServers { + registry: Arc, +} + +impl ListModelServers { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Lists configs. + /// + /// # Errors + /// [`AppError::ModelServer`] on registry failure. + pub async fn execute(&self) -> Result { + 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, +} + +impl SaveModelServer { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Saves a config. + /// + /// # Errors + /// [`AppError::ModelServer`] on registry failure. + pub async fn execute( + &self, + input: SaveModelServerInput, + ) -> Result { + 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, + profiles: Arc, +} + +impl DeleteModelServer { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc, profiles: Arc) -> 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, + probe: Arc, + process: Arc, + runtime: Arc, + fs: Arc, + events: Arc, + active: Mutex>, + readiness: ReadinessPolicy, +} + +impl EnsureLocalModelServer { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + probe: Arc, + process: Arc, + runtime: Arc, + fs: Arc, + events: Arc, + ) -> 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 { + 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 = 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(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result { + 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", + } +} diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs new file mode 100644 index 0000000..3f5fe15 --- /dev/null +++ b/crates/application/tests/model_server.rs @@ -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>); + +#[async_trait] +impl ModelServerRegistry for FakeRegistry { + async fn get( + &self, + id: &LocalModelServerId, + ) -> Result, ModelServerError> { + Ok(self.0.lock().unwrap().get(id).cloned()) + } + + async fn list(&self) -> Result, 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>); + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, 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 { + 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>); + +impl FakeProbe { + fn new(statuses: Vec) -> Self { + Self(Mutex::new(statuses.into())) + } +} + +#[async_trait] +impl ModelServerProbe for FakeProbe { + async fn probe( + &self, + _endpoint: &ModelServerEndpoint, + ) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .pop_front() + .unwrap_or(ModelServerStatus::Unreachable)) + } +} + +#[derive(Default)] +struct FakeProcess { + spawns: Mutex>, + kills: Mutex>, + statuses: Mutex>, +} + +#[async_trait] +impl ManagedProcess for FakeProcess { + async fn spawn(&self, spec: SpawnSpec) -> Result { + 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 { + 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 { + 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>, +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, _path: &RemotePath) -> Result, 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 { + 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, FsError> { + Ok(Vec::new()) + } + + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeEvents(Mutex>); + +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, + probe: Arc, + process: Arc, + fs: Arc, + events: Arc, +) -> 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(®istry), + 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(®istry), + 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(®istry), + 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(®istry), + 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(®istry), + 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(®istry) as Arc, + profiles, + ); + + usecase + .execute(DeleteModelServerInput { server_id: sid(9) }) + .await + .unwrap(); + + assert!(registry.get(&sid(9)).await.unwrap().is_none()); +} diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index d7d8bae..4c957dd 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -13,15 +13,17 @@ use async_trait::async_trait; use domain::ids::ProfileId; use domain::ports::{ - AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError, + AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, + StoreError, }; -use domain::profile::{AgentProfile, ContextInjection}; +use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter}; use domain::project::ProjectPath; use application::{ - reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, - DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, - ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, + reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed, + CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, + DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles, + ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, }; // --------------------------------------------------------------------------- @@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime { } } +struct SeqIds(Mutex>); + +impl SeqIds { + fn new(ids: Vec) -> Self { + Self(Mutex::new(ids)) + } +} + +impl IdGenerator for SeqIds { + fn new_uuid(&self) -> uuid::Uuid { + self.0.lock().unwrap().remove(0) + } +} + fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), @@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() { assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } +#[tokio::test] +async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() { + let store = FakeProfileStore::default(); + let clone = CloneOpenCodeProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])), + ); + + let out = clone + .execute(CloneOpenCodeProfileFromSeedInput { + name: Some("OpenCode llama.cpp 2".to_owned()), + opencode: Some( + OpenCodeConfig::new( + "http://localhost:8081/v1", + None, + "qwen3-coder-70b", + Some(false), + Some(true), + ) + .unwrap(), + ), + }) + .await + .unwrap(); + + let seed_id = reference_profile_id("opencode-llamacpp"); + assert_ne!(out.profile.id, seed_id); + assert_eq!( + out.profile.id, + ProfileId::from_uuid(uuid::Uuid::from_u128(3601)) + ); + assert_eq!(out.profile.name, "OpenCode llama.cpp 2"); + assert_eq!( + out.profile.structured_adapter, + Some(StructuredAdapter::OpenCode) + ); + assert_eq!( + out.profile + .opencode + .as_ref() + .map(|config| config.base_url.as_str()), + Some("http://localhost:8081/v1") + ); + + let profiles = store.0.lock().unwrap().profiles.clone(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].id, out.profile.id); +} + +#[tokio::test] +async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() { + let store = FakeProfileStore::default(); + let save = SaveProfile::new(Arc::new(store.clone())); + let mut seed = reference_profiles() + .into_iter() + .find(|profile| profile.id == reference_profile_id("opencode-llamacpp")) + .expect("seed exists"); + seed.name = "Edited local OpenCode seed".to_owned(); + save.execute(SaveProfileInput { + profile: seed.clone(), + }) + .await + .unwrap(); + + let clone = CloneOpenCodeProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])), + ); + let out = clone + .execute(CloneOpenCodeProfileFromSeedInput { + name: None, + opencode: None, + }) + .await + .unwrap(); + + assert_eq!(out.profile.name, "Edited local OpenCode seed copy"); + assert_eq!(out.profile.opencode, seed.opencode); + let profiles = store.0.lock().unwrap().profiles.clone(); + assert_eq!( + profiles + .iter() + .filter(|profile| profile.id == reference_profile_id("opencode-llamacpp")) + .count(), + 1, + "the canonical seed must be preserved, not recreated as a duplicate" + ); + assert_eq!( + profiles + .iter() + .filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode)) + .count(), + 2, + "ProfileId is the identity: multiple OpenCode profiles can coexist" + ); +} + // --------------------------------------------------------------------------- // ReferenceProfiles / catalogue // --------------------------------------------------------------------------- diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 43b3fd5..36e61a9 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -3,7 +3,8 @@ use crate::conversation::ConversationParty; 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::mailbox::TicketId; @@ -46,6 +47,24 @@ pub enum DomainEvent { /// The session it runs in. 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. BackgroundTaskStarted { /// The owning project. diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index b65e945..238e79b 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -68,6 +68,10 @@ typed_id!( /// Identifies an [`crate::profile::AgentProfile`]. ProfileId ); +typed_id!( + /// Identifies a local model server configuration. + LocalModelServerId +); typed_id!( /// Identifies a [`crate::skill::Skill`]. SkillId diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 45d096e..b009457 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -49,6 +49,7 @@ pub mod mailbox; pub mod markdown; pub mod memory; pub mod memory_harvest; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod ports; @@ -72,8 +73,8 @@ mod validation; pub use error::DomainError; pub use ids::{ - AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, - SprintId, TabId, TaskId, TemplateId, WindowId, + AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId, + SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -153,6 +154,12 @@ pub use memory_harvest::{ 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 terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; diff --git a/crates/domain/src/model_server.rs b/crates/domain/src/model_server.rs new file mode 100644 index 0000000..a2816de --- /dev/null +++ b/crates/domain/src/model_server.rs @@ -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, port: u16) -> Result { + 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 { + 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::() + .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) -> Result { + 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) -> Result { + 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, + /// 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, + label: impl Into, + path: Option, + served_name: impl Into, + ) -> Result { + 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, + /// Extra argv passed to the runtime. + #[serde(default)] + pub args: Vec, + /// 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, + endpoint: ModelServerEndpoint, + model: LocalModelRef, + binary: Option, + args: Vec, + auto_start: bool, + stop_policy: StopPolicy, + ) -> Result { + 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")); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 9de29a6..b0f3805 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,12 +34,15 @@ use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; 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::{ Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, }; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; +use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus}; use crate::permission::ProjectPermissions; use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; @@ -206,6 +209,27 @@ pub struct Output { pub stderr: Vec, } +/// 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, + }, + /// Handle is unknown to the adapter. + Unknown, +} + /// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RemotePath(pub String); @@ -502,6 +526,41 @@ pub enum ProcessError { 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`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum FsError { @@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync { async fn run(&self, spec: SpawnSpec) -> Result; } +/// 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; +} + +/// 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; + + /// 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; +} + +/// 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; +} + +/// 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, ModelServerError>; + + /// Lists all server configs. + /// + /// # Errors + /// [`ModelServerError`] on store failure. + async fn list(&self) -> Result, 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. #[async_trait] pub trait FileSystem: Send + Sync { diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 2c0a613..e550de8 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::error::DomainError; -use crate::ids::ProfileId; +use crate::ids::{LocalModelServerId, ProfileId}; use crate::permission::ProjectorKey; /// 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`. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachment: Option, + /// 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, } impl OpenCodeConfig { @@ -325,9 +330,17 @@ impl OpenCodeConfig { model, reasoning, 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. #[must_use] pub fn reasoning_enabled(&self) -> bool { @@ -1107,7 +1120,7 @@ impl AgentProfile { #[cfg(test)] mod mcp_tests { use super::*; - use crate::ids::ProfileId; + use crate::ids::{LocalModelServerId, ProfileId}; /// A reference profile without any MCP capability (the historical shape). fn profile_without_mcp() -> AgentProfile { @@ -1225,6 +1238,46 @@ mod mcp_tests { 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] fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() { let profile = profile_without_mcp() diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1da72b2..3a4e1ba 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -26,6 +26,7 @@ pub mod input; pub mod inspector; pub mod issues; pub mod mailbox; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod process; @@ -64,6 +65,9 @@ pub use inspector::{ }; pub use issues::{FsIssueNumberAllocator, FsIssueStore}; pub use mailbox::InMemoryMailbox; +pub use model_server::{ + FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, +}; pub use orchestrator::mcp::{ McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider, ToolPolicyRegistry, diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs new file mode 100644 index 0000000..df0014e --- /dev/null +++ b/crates/infrastructure/src/model_server/mod.rs @@ -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 { + 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) -> 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 { + 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 { + 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 { + 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>, +} + +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 { + 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 { + 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, +} + +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, + 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, app_data_dir: impl Into) -> 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 { + 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, ModelServerError> { + Ok(self + .read_doc() + .await? + .servers + .into_iter() + .find(|server| &server.id == id)) + } + + async fn list(&self) -> Result, 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 + } +} diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs new file mode 100644 index 0000000..997959c --- /dev/null +++ b/crates/infrastructure/tests/model_server.rs @@ -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 = 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]); +}