feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)
Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri). #35 — Serveur de modèle local intégré (llama.cpp) : - domain: model_server.rs (agrégat + statut), ports ModelServerProbe / ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements model_server_status_changed et agent_launch_failed. - application: use case EnsureLocalModelServer branché sur LaunchAgent. - infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, FsModelServerRegistry. - app-tauri: DTO plat LocalModelServerConfigDto, commandes list/save/delete_model_server avec garde model_server_in_use, wiring. #36 — Profils OpenCode locaux multiples : - domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id. - application: use case CloneOpenCodeProfileFromSeed. - app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring. Tests verts (exécution réelle) : domain+application OK, app-tauri dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2, application model_server+profile_usecases 19/19, cargo build workspace Finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -34,28 +34,30 @@ use crate::dto::{
|
|||||||
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
|
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto,
|
||||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
||||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||||
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
|
WriteTerminalRequestDto, parse_model_server_id, save_model_server_input,
|
||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@ -793,6 +795,25 @@ pub async fn save_profile(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
|
||||||
|
/// from the canonical `opencode-llamacpp` seed/template.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a
|
||||||
|
/// blank requested name).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clone_opencode_profile_from_seed(
|
||||||
|
request: CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ProfileDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.clone_opencode_profile_from_seed
|
||||||
|
.execute(request.into())
|
||||||
|
.await
|
||||||
|
.map(ProfileDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// `delete_profile` — delete a profile by id.
|
/// `delete_profile` — delete a profile by id.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -836,6 +857,73 @@ pub async fn configure_profiles(
|
|||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `list_model_servers` — list configured local model servers.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] on registry failure.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_model_servers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ModelServerConfigListDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.list_model_servers
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map(ModelServerConfigListDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `save_model_server` — upsert a local model server config.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] on registry failure.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_model_server(
|
||||||
|
request: SaveModelServerRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<ModelServerConfigDto, ErrorDto> {
|
||||||
|
let server_id = parse_model_server_id(&request.config.id)?;
|
||||||
|
let existing = state
|
||||||
|
.list_model_servers
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?
|
||||||
|
.servers
|
||||||
|
.into_iter()
|
||||||
|
.find(|config| config.id == server_id);
|
||||||
|
let input = save_model_server_input(request, existing.as_ref())?;
|
||||||
|
state
|
||||||
|
.save_model_server
|
||||||
|
.execute(input)
|
||||||
|
.await
|
||||||
|
.map(ModelServerConfigDto::from)
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `delete_model_server` — delete a local model server config when unused.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `model_server_in_use` if any OpenCode profile still references it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_model_server(
|
||||||
|
server_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
let server_id = parse_model_server_id(&server_id)?;
|
||||||
|
state
|
||||||
|
.delete_model_server
|
||||||
|
.execute(application::DeleteModelServerInput { server_id })
|
||||||
|
.await
|
||||||
|
.map_err(model_server_command_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_server_command_error(err: AppError) -> ErrorDto {
|
||||||
|
match err {
|
||||||
|
AppError::ModelServer { code, message } => ErrorDto { code, message },
|
||||||
|
other => ErrorDto::from(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput,
|
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
||||||
DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability,
|
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
||||||
ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput,
|
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
||||||
|
SaveProfileInput, SaveProfileOutput,
|
||||||
};
|
};
|
||||||
use domain::profile::AgentProfile;
|
use domain::profile::{AgentProfile, OpenCodeConfig};
|
||||||
use domain::ProfileId;
|
use domain::ProfileId;
|
||||||
|
|
||||||
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
||||||
@ -776,6 +777,12 @@ impl From<SaveProfileOutput> for ProfileDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
||||||
|
fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
|
||||||
|
Self(out.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
|
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -838,6 +845,27 @@ impl From<SaveProfileRequestDto> for SaveProfileInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `clone_opencode_profile_from_seed`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CloneOpenCodeProfileFromSeedRequestDto {
|
||||||
|
/// Optional display name for the new profile.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Optional OpenCode config override. When omitted, the seed config is copied.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode: Option<OpenCodeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
|
||||||
|
fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
|
||||||
|
Self {
|
||||||
|
name: dto.name,
|
||||||
|
opencode: dto.opencode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `configure_profiles` (closes the first run).
|
/// Request DTO for `configure_profiles` (closes the first run).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -879,6 +907,252 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Local model servers (B35)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||||
|
use domain::model_server::{
|
||||||
|
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||||
|
ModelServerEndpoint,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
|
|
||||||
|
/// Local model-server implementation on the IPC wire.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ModelServerKindDto {
|
||||||
|
/// llama.cpp `llama-server`.
|
||||||
|
LlamaCpp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LocalModelServerKind> for ModelServerKindDto {
|
||||||
|
fn from(kind: LocalModelServerKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelServerKindDto> for LocalModelServerKind {
|
||||||
|
fn from(kind: ModelServerKindDto) -> Self {
|
||||||
|
match kind {
|
||||||
|
ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop policy on the IPC wire.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum StopPolicyDto {
|
||||||
|
/// Keep the process alive.
|
||||||
|
KeepAlive,
|
||||||
|
/// Stop when no longer used.
|
||||||
|
StopWhenUnused,
|
||||||
|
/// Stop when the application exits.
|
||||||
|
StopOnAppExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StopPolicy> for StopPolicyDto {
|
||||||
|
fn from(policy: StopPolicy) -> Self {
|
||||||
|
match policy {
|
||||||
|
StopPolicy::KeepAlive => Self::KeepAlive,
|
||||||
|
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
|
||||||
|
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StopPolicyDto> for StopPolicy {
|
||||||
|
fn from(policy: StopPolicyDto) -> Self {
|
||||||
|
match policy {
|
||||||
|
StopPolicyDto::KeepAlive => Self::KeepAlive,
|
||||||
|
StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
|
||||||
|
StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local model-server config crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelServerConfigDto {
|
||||||
|
/// Stable local server config id.
|
||||||
|
pub id: String,
|
||||||
|
/// Server implementation kind.
|
||||||
|
pub kind: ModelServerKindDto,
|
||||||
|
/// Display name.
|
||||||
|
pub name: String,
|
||||||
|
/// OpenAI-compatible base URL.
|
||||||
|
#[serde(rename = "baseURL")]
|
||||||
|
pub base_url: String,
|
||||||
|
/// TCP port.
|
||||||
|
pub port: u16,
|
||||||
|
/// Optional explicit `.gguf` model path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_path: Option<String>,
|
||||||
|
/// Served model name exposed to OpenCode.
|
||||||
|
pub served_model_name: String,
|
||||||
|
/// Optional explicit `llama-server` executable path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binary_path: Option<String>,
|
||||||
|
/// Extra argv entries.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Whether IdeA should start the server lazily.
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// Stop policy.
|
||||||
|
pub stop_policy: StopPolicyDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelServerConfigDto {
|
||||||
|
/// Maps a domain config to the flat IPC DTO.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_domain(config: LocalModelServerConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
id: config.id.to_string(),
|
||||||
|
kind: config.kind.into(),
|
||||||
|
name: config.name,
|
||||||
|
base_url: config.endpoint.base_url,
|
||||||
|
port: config.endpoint.port,
|
||||||
|
model_path: config
|
||||||
|
.model
|
||||||
|
.path
|
||||||
|
.map(|path| path.as_str().to_owned()),
|
||||||
|
served_model_name: config.model.served_name,
|
||||||
|
binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
|
||||||
|
args: config.args,
|
||||||
|
auto_start: config.auto_start,
|
||||||
|
stop_policy: config.stop_policy.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the flat IPC DTO into the domain config, preserving internal model id
|
||||||
|
/// from the existing config when available.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
|
||||||
|
pub fn into_domain(
|
||||||
|
self,
|
||||||
|
existing: Option<&LocalModelServerConfig>,
|
||||||
|
) -> Result<LocalModelServerConfig, ErrorDto> {
|
||||||
|
let server_id = parse_model_server_id(&self.id)?;
|
||||||
|
let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
|
let model_path = optional_non_empty(self.model_path)
|
||||||
|
.map(ModelPath::new)
|
||||||
|
.transpose()
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
|
let model_id = existing
|
||||||
|
.filter(|config| config.id == server_id)
|
||||||
|
.map(|config| config.model.id.clone())
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
let label = non_empty_or_fallback(&self.served_model_name, &self.name);
|
||||||
|
let model = LocalModelRef::new(
|
||||||
|
model_id,
|
||||||
|
label,
|
||||||
|
model_path,
|
||||||
|
self.served_model_name.clone(),
|
||||||
|
)
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
|
let binary = optional_non_empty(self.binary_path)
|
||||||
|
.map(ExecutablePath::new)
|
||||||
|
.transpose()
|
||||||
|
.map_err(invalid_domain_error)?;
|
||||||
|
LocalModelServerConfig::new(
|
||||||
|
server_id,
|
||||||
|
self.kind.into(),
|
||||||
|
self.name,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
binary,
|
||||||
|
self.args,
|
||||||
|
self.auto_start,
|
||||||
|
self.stop_policy.into(),
|
||||||
|
)
|
||||||
|
.map_err(invalid_domain_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List response for local model servers.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);
|
||||||
|
|
||||||
|
impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||||
|
fn from(out: ListModelServersOutput) -> Self {
|
||||||
|
Self(
|
||||||
|
out.servers
|
||||||
|
.into_iter()
|
||||||
|
.map(ModelServerConfigDto::from_domain)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `save_model_server`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveModelServerRequestDto {
|
||||||
|
/// Config to upsert.
|
||||||
|
pub config: ModelServerConfigDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SaveModelServerOutput> for ModelServerConfigDto {
|
||||||
|
fn from(out: SaveModelServerOutput) -> Self {
|
||||||
|
Self::from_domain(out.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a local model-server id string.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
|
||||||
|
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
|
||||||
|
uuid::Uuid::parse_str(raw)
|
||||||
|
.map(LocalModelServerId::from_uuid)
|
||||||
|
.map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("invalid model server id: {raw}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a save-model-server input after the caller has resolved the existing
|
||||||
|
/// config, if any.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ErrorDto`] if DTO-to-domain validation fails.
|
||||||
|
pub fn save_model_server_input(
|
||||||
|
request: SaveModelServerRequestDto,
|
||||||
|
existing: Option<&LocalModelServerConfig>,
|
||||||
|
) -> Result<SaveModelServerInput, ErrorDto> {
|
||||||
|
Ok(SaveModelServerInput {
|
||||||
|
config: request.config.into_domain(existing)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||||
|
raw.map(|value| value.trim().to_owned())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
|
||||||
|
let primary = primary.trim();
|
||||||
|
if primary.is_empty() {
|
||||||
|
fallback.trim().to_owned()
|
||||||
|
} else {
|
||||||
|
primary.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
|
||||||
|
ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter};
|
|||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::events::{DomainEvent, OrchestrationSource};
|
use domain::events::{DomainEvent, OrchestrationSource};
|
||||||
use domain::input::AgentLiveness;
|
use domain::input::AgentLiveness;
|
||||||
|
use domain::model_server::ModelServerLifecycleStatus;
|
||||||
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||||
use infrastructure::TokioBroadcastEventBus;
|
use infrastructure::TokioBroadcastEventBus;
|
||||||
|
|
||||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||||
pub const DOMAIN_EVENT: &str = "domain://event";
|
pub const DOMAIN_EVENT: &str = "domain://event";
|
||||||
|
/// Dedicated Tauri event for local model-server status changes.
|
||||||
|
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
|
||||||
|
/// Dedicated Tauri event for launch failures.
|
||||||
|
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";
|
||||||
|
|
||||||
|
/// Model-server lifecycle status on the Tauri wire.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "state", rename_all = "camelCase")]
|
||||||
|
pub enum ModelServerStatusDto {
|
||||||
|
/// No server is configured for this profile.
|
||||||
|
NotConfigured,
|
||||||
|
/// Readiness probing is in progress.
|
||||||
|
Probing,
|
||||||
|
/// IdeA is starting the process.
|
||||||
|
Starting,
|
||||||
|
/// Server is ready.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Ready {
|
||||||
|
/// `true` when an existing server was reused.
|
||||||
|
reused: bool,
|
||||||
|
},
|
||||||
|
/// Server preparation failed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Failed {
|
||||||
|
/// Stable failure code.
|
||||||
|
code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
|
||||||
|
fn from(status: &ModelServerLifecycleStatus) -> Self {
|
||||||
|
match status {
|
||||||
|
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
|
||||||
|
ModelServerLifecycleStatus::Probing => Self::Probing,
|
||||||
|
ModelServerLifecycleStatus::Starting => Self::Starting,
|
||||||
|
ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
|
||||||
|
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
|
||||||
|
code: code.clone(),
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelServerStatusChangedDto {
|
||||||
|
/// Local model server id.
|
||||||
|
pub server_id: String,
|
||||||
|
/// Lifecycle status.
|
||||||
|
pub status: ModelServerStatusDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for [`AGENT_LAUNCH_FAILED`].
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentLaunchFailedDto {
|
||||||
|
/// Agent id.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Failure cause namespace.
|
||||||
|
pub cause: String,
|
||||||
|
/// Stable error code.
|
||||||
|
pub code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
||||||
///
|
///
|
||||||
@ -42,6 +112,26 @@ pub enum DomainEventDto {
|
|||||||
/// Session id.
|
/// Session id.
|
||||||
session_id: String,
|
session_id: String,
|
||||||
},
|
},
|
||||||
|
/// An agent launch failed before runtime session creation.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentLaunchFailed {
|
||||||
|
/// Agent id.
|
||||||
|
agent_id: String,
|
||||||
|
/// Failure cause namespace.
|
||||||
|
cause: String,
|
||||||
|
/// Stable error code.
|
||||||
|
code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// Local model-server status changed.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
ModelServerStatusChanged {
|
||||||
|
/// Local model server id.
|
||||||
|
server_id: String,
|
||||||
|
/// Lifecycle status.
|
||||||
|
status: ModelServerStatusDto,
|
||||||
|
},
|
||||||
/// An agent exited.
|
/// An agent exited.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
AgentExited {
|
AgentExited {
|
||||||
@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::AgentLaunchFailed {
|
||||||
|
agent_id,
|
||||||
|
cause,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => Self::AgentLaunchFailed {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
cause: cause.clone(),
|
||||||
|
code: code.clone(),
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||||
|
Self::ModelServerStatusChanged {
|
||||||
|
server_id: server_id.to_string(),
|
||||||
|
status: status.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
code: *code,
|
code: *code,
|
||||||
@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
|||||||
}
|
}
|
||||||
let dto = DomainEventDto::from(&event);
|
let dto = DomainEventDto::from(&event);
|
||||||
let _ = app.emit(DOMAIN_EVENT, dto);
|
let _ = app.emit(DOMAIN_EVENT, dto);
|
||||||
|
match &event {
|
||||||
|
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||||
|
let _ = app.emit(
|
||||||
|
MODEL_SERVER_STATUS_CHANGED,
|
||||||
|
ModelServerStatusChangedDto {
|
||||||
|
server_id: server_id.to_string(),
|
||||||
|
status: status.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DomainEvent::AgentLaunchFailed {
|
||||||
|
agent_id,
|
||||||
|
cause,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
let _ = app.emit(
|
||||||
|
AGENT_LAUNCH_FAILED,
|
||||||
|
AgentLaunchFailedDto {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
cause: cause.clone(),
|
||||||
|
code: code.clone(),
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The bus dropped some events for this slow receiver; keep going.
|
// The bus dropped some events for this slow receiver; keep going.
|
||||||
Err(RecvError::Lagged(_)) => continue,
|
Err(RecvError::Lagged(_)) => continue,
|
||||||
@ -1008,13 +1143,47 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::ids::AgentId;
|
use domain::ids::AgentId;
|
||||||
use domain::mailbox::TicketId;
|
use domain::mailbox::TicketId;
|
||||||
use domain::ProjectId;
|
use domain::{LocalModelServerId, ProjectId};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn agent(n: u128) -> AgentId {
|
fn agent(n: u128) -> AgentId {
|
||||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn server(n: u128) -> LocalModelServerId {
|
||||||
|
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
||||||
|
server_id: server(35),
|
||||||
|
status: ModelServerLifecycleStatus::Ready { reused: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||||
|
assert_eq!(json["type"], "modelServerStatusChanged");
|
||||||
|
assert_eq!(json["serverId"], server(35).to_string());
|
||||||
|
assert_eq!(json["status"]["state"], "ready");
|
||||||
|
assert_eq!(json["status"]["reused"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_launch_failed_relays_model_server_cause() {
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
|
||||||
|
agent_id: agent(36),
|
||||||
|
cause: "model_server".to_owned(),
|
||||||
|
code: "path_not_accessible".to_owned(),
|
||||||
|
message: "path not accessible: /models/missing.gguf".to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||||
|
assert_eq!(json["type"], "agentLaunchFailed");
|
||||||
|
assert_eq!(json["agentId"], agent(36).to_string());
|
||||||
|
assert_eq!(json["cause"], "model_server");
|
||||||
|
assert_eq!(json["code"], "path_not_accessible");
|
||||||
|
}
|
||||||
|
|
||||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||||
|
|||||||
@ -106,6 +106,8 @@ pub fn run() {
|
|||||||
// If we killed first, the registry would be empty and every
|
// If we killed first, the registry would be empty and every
|
||||||
// agent would be persisted as "closed".
|
// agent would be persisted as "closed".
|
||||||
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
||||||
|
let model_servers =
|
||||||
|
std::sync::Arc::clone(&state.ensure_local_model_server);
|
||||||
let open_projects = state.open_project_ids();
|
let open_projects = state.open_project_ids();
|
||||||
let handles = state.terminal_sessions.handles();
|
let handles = state.terminal_sessions.handles();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
@ -119,6 +121,7 @@ pub fn run() {
|
|||||||
for h in handles {
|
for h in handles {
|
||||||
let _ = pty.kill(&h).await;
|
let _ = pty.kill(&h).await;
|
||||||
}
|
}
|
||||||
|
let _ = model_servers.stop_on_app_exit().await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -156,8 +159,12 @@ pub fn run() {
|
|||||||
commands::detect_profiles,
|
commands::detect_profiles,
|
||||||
commands::list_profiles,
|
commands::list_profiles,
|
||||||
commands::save_profile,
|
commands::save_profile,
|
||||||
|
commands::clone_opencode_profile_from_seed,
|
||||||
commands::delete_profile,
|
commands::delete_profile,
|
||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
|
commands::list_model_servers,
|
||||||
|
commands::save_model_server,
|
||||||
|
commands::delete_model_server,
|
||||||
commands::list_embedder_profiles,
|
commands::list_embedder_profiles,
|
||||||
commands::save_embedder_profile,
|
commands::save_embedder_profile,
|
||||||
commands::delete_embedder_profile,
|
commands::delete_embedder_profile,
|
||||||
|
|||||||
@ -14,32 +14,32 @@ use std::sync::{Arc, Mutex};
|
|||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
||||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
|
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject,
|
||||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
||||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
||||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues,
|
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles,
|
||||||
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
||||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow,
|
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
||||||
ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
||||||
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
||||||
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||||
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||||
RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
|
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
|
||||||
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand,
|
SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
|
||||||
StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
|
||||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
|
||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -69,10 +69,11 @@ use infrastructure::{
|
|||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
||||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||||
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository,
|
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
||||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem,
|
||||||
|
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
@ -776,6 +777,8 @@ pub struct AppState {
|
|||||||
pub list_profiles: Arc<ListProfiles>,
|
pub list_profiles: Arc<ListProfiles>,
|
||||||
/// Save (upsert) a profile.
|
/// Save (upsert) a profile.
|
||||||
pub save_profile: Arc<SaveProfile>,
|
pub save_profile: Arc<SaveProfile>,
|
||||||
|
/// Create a new OpenCode profile instance from the canonical seed.
|
||||||
|
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||||
/// Delete a profile.
|
/// Delete a profile.
|
||||||
pub delete_profile: Arc<DeleteProfile>,
|
pub delete_profile: Arc<DeleteProfile>,
|
||||||
/// Persist the batch of chosen profiles (closes the first run).
|
/// Persist the batch of chosen profiles (closes the first run).
|
||||||
@ -784,6 +787,14 @@ pub struct AppState {
|
|||||||
pub reference_profiles: Arc<ReferenceProfiles>,
|
pub reference_profiles: Arc<ReferenceProfiles>,
|
||||||
/// Whether the first-run wizard should show + the reference catalogue.
|
/// Whether the first-run wizard should show + the reference catalogue.
|
||||||
pub first_run_state: Arc<FirstRunState>,
|
pub first_run_state: Arc<FirstRunState>,
|
||||||
|
/// Ensures local llama.cpp model servers for OpenCode profiles.
|
||||||
|
pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
|
||||||
|
/// Lists local model server configurations.
|
||||||
|
pub list_model_servers: Arc<ListModelServers>,
|
||||||
|
/// Saves local model server configurations.
|
||||||
|
pub save_model_server: Arc<SaveModelServer>,
|
||||||
|
/// Deletes local model server configurations when unused.
|
||||||
|
pub delete_model_server: Arc<DeleteModelServer>,
|
||||||
/// The local PTY adapter, kept port-typed so the presentation layer can
|
/// The local PTY adapter, kept port-typed so the presentation layer can
|
||||||
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
|
/// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport).
|
||||||
pub pty_port: Arc<dyn PtyPort>,
|
pub pty_port: Arc<dyn PtyPort>,
|
||||||
@ -1183,11 +1194,41 @@ impl AppState {
|
|||||||
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
||||||
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
|
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port)));
|
||||||
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
|
let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port)));
|
||||||
|
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||||
|
Arc::clone(&profile_store_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
|
let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port)));
|
||||||
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
||||||
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
||||||
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
||||||
|
|
||||||
|
let model_server_registry = Arc::new(FsModelServerRegistry::new(
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new(
|
||||||
|
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
|
||||||
|
Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||||
|
as Arc<dyn domain::ports::ModelServerProbe>,
|
||||||
|
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
|
||||||
|
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
||||||
|
Arc::clone(&fs_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let model_server_registry_port =
|
||||||
|
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
|
||||||
|
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(
|
||||||
|
&model_server_registry_port,
|
||||||
|
)));
|
||||||
|
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
|
||||||
|
&model_server_registry_port,
|
||||||
|
)));
|
||||||
|
let delete_model_server = Arc::new(DeleteModelServer::new(
|
||||||
|
Arc::clone(&model_server_registry_port),
|
||||||
|
Arc::clone(&profile_store_port),
|
||||||
|
));
|
||||||
|
|
||||||
let pty_bridge = Arc::new(PtyBridge::new());
|
let pty_bridge = Arc::new(PtyBridge::new());
|
||||||
// Twin of the PTY bridge for structured chat sessions (§17.7): routes a
|
// Twin of the PTY bridge for structured chat sessions (§17.7): routes a
|
||||||
// turn's ReplyChunks to the owning chat cell and retains the conversation
|
// turn's ReplyChunks to the owning chat cell and retains the conversation
|
||||||
@ -1499,7 +1540,8 @@ impl AppState {
|
|||||||
// parse ⇒ section omise, jamais d'échec de lancement.
|
// parse ⇒ section omise, jamais d'échec de lancement.
|
||||||
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
||||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
}) as Arc<dyn LiveStateLeanProvider>),
|
}) as Arc<dyn LiveStateLeanProvider>)
|
||||||
|
.with_local_model_server(Arc::clone(&ensure_local_model_server)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Inter-agent launcher: same context, memory, permissions and live-state
|
// Inter-agent launcher: same context, memory, permissions and live-state
|
||||||
@ -1530,6 +1572,7 @@ impl AppState {
|
|||||||
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
||||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
}) as Arc<dyn LiveStateLeanProvider>)
|
}) as Arc<dyn LiveStateLeanProvider>)
|
||||||
|
.with_local_model_server(Arc::clone(&ensure_local_model_server))
|
||||||
.with_structured(
|
.with_structured(
|
||||||
Arc::clone(&session_factory),
|
Arc::clone(&session_factory),
|
||||||
Arc::clone(&structured_sessions),
|
Arc::clone(&structured_sessions),
|
||||||
@ -2196,10 +2239,15 @@ impl AppState {
|
|||||||
detect_profiles,
|
detect_profiles,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
save_profile,
|
save_profile,
|
||||||
|
clone_opencode_profile_from_seed,
|
||||||
delete_profile,
|
delete_profile,
|
||||||
configure_profiles,
|
configure_profiles,
|
||||||
reference_profiles,
|
reference_profiles,
|
||||||
first_run_state,
|
first_run_state,
|
||||||
|
ensure_local_model_server,
|
||||||
|
list_model_servers,
|
||||||
|
save_model_server,
|
||||||
|
delete_model_server,
|
||||||
pty_port,
|
pty_port,
|
||||||
terminal_sessions,
|
terminal_sessions,
|
||||||
event_bus,
|
event_bus,
|
||||||
|
|||||||
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! DTO contract tests for local model-server IPC.
|
||||||
|
|
||||||
|
use app_tauri_lib::dto::{
|
||||||
|
ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto,
|
||||||
|
save_model_server_input,
|
||||||
|
};
|
||||||
|
use domain::model_server::{
|
||||||
|
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||||
|
ModelServerEndpoint,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn sid(n: u128) -> LocalModelServerId {
|
||||||
|
LocalModelServerId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConfig {
|
||||||
|
LocalModelServerConfig::new(
|
||||||
|
id,
|
||||||
|
LocalModelServerKind::LlamaCpp,
|
||||||
|
"Local Qwen",
|
||||||
|
ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(),
|
||||||
|
LocalModelRef::new(
|
||||||
|
model_id,
|
||||||
|
"Qwen",
|
||||||
|
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||||
|
"qwen3-coder",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()),
|
||||||
|
vec!["--ctx-size".to_owned(), "4096".to_owned()],
|
||||||
|
true,
|
||||||
|
StopPolicy::StopOnAppExit,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_serialises_flat_camelcase_wire_shape() {
|
||||||
|
let dto = ModelServerConfigDto::from_domain(domain_config(sid(35), "internal-model-id"));
|
||||||
|
|
||||||
|
let value = serde_json::to_value(dto).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["id"], sid(35).to_string());
|
||||||
|
assert_eq!(value["kind"], "llamaCpp");
|
||||||
|
assert_eq!(value["baseURL"], "http://localhost:8080/v1");
|
||||||
|
assert_eq!(value["modelPath"], "/models/qwen.gguf");
|
||||||
|
assert_eq!(value["servedModelName"], "qwen3-coder");
|
||||||
|
assert_eq!(value["binaryPath"], "/usr/bin/llama-server");
|
||||||
|
assert_eq!(value["stopPolicy"], "stopOnAppExit");
|
||||||
|
assert!(value.get("model").is_none(), "domain model must not leak");
|
||||||
|
assert!(value.get("endpoint").is_none(), "domain endpoint must not leak");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() {
|
||||||
|
let raw = json!({
|
||||||
|
"id": sid(36).to_string(),
|
||||||
|
"kind": "llamaCpp",
|
||||||
|
"name": "Local Qwen",
|
||||||
|
"baseURL": "http://localhost:8081",
|
||||||
|
"port": 8081,
|
||||||
|
"modelPath": "/models/qwen.gguf",
|
||||||
|
"servedModelName": "qwen3-coder",
|
||||||
|
"binaryPath": "/usr/bin/llama-server",
|
||||||
|
"args": ["--ctx-size", "4096"],
|
||||||
|
"autoStart": true,
|
||||||
|
"stopPolicy": "stopOnAppExit"
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = SaveModelServerRequestDto {
|
||||||
|
config: serde_json::from_value(raw).unwrap(),
|
||||||
|
};
|
||||||
|
let input = save_model_server_input(request, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(input.config.id, sid(36));
|
||||||
|
assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp);
|
||||||
|
assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1");
|
||||||
|
assert_eq!(
|
||||||
|
input.config.model.path.unwrap().as_str(),
|
||||||
|
"/models/qwen.gguf"
|
||||||
|
);
|
||||||
|
assert_eq!(input.config.model.served_name, "qwen3-coder");
|
||||||
|
assert_eq!(input.config.model.label, "qwen3-coder");
|
||||||
|
assert!(!input.config.model.id.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||||
|
let existing = domain_config(sid(37), "stable-internal-id");
|
||||||
|
let dto = ModelServerConfigDto {
|
||||||
|
id: sid(37).to_string(),
|
||||||
|
kind: ModelServerKindDto::LlamaCpp,
|
||||||
|
name: "Updated Qwen".to_owned(),
|
||||||
|
base_url: "http://localhost:8082/v1".to_owned(),
|
||||||
|
port: 8082,
|
||||||
|
model_path: Some("/models/qwen-updated.gguf".to_owned()),
|
||||||
|
served_model_name: "qwen3-coder-updated".to_owned(),
|
||||||
|
binary_path: Some("/usr/bin/llama-server".to_owned()),
|
||||||
|
args: Vec::new(),
|
||||||
|
auto_start: true,
|
||||||
|
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = dto.into_domain(Some(&existing)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.model.id, "stable-internal-id");
|
||||||
|
assert_eq!(config.model.served_name, "qwen3-coder-updated");
|
||||||
|
}
|
||||||
@ -3,15 +3,16 @@
|
|||||||
//! error behaviour.
|
//! error behaviour.
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||||
|
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput,
|
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
|
||||||
ProfileAvailability, SaveProfileInput,
|
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
|
||||||
};
|
};
|
||||||
use domain::ids::ProfileId;
|
use domain::ids::{LocalModelServerId, ProfileId};
|
||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -94,6 +95,47 @@ fn save_request_deserialises_profile() {
|
|||||||
assert!(input.profile.detect.is_none());
|
assert!(input.profile.detect.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
|
||||||
|
let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35));
|
||||||
|
let raw = json!({
|
||||||
|
"name": "Local Qwen",
|
||||||
|
"opencode": {
|
||||||
|
"baseURL": "http://localhost:8081/v1",
|
||||||
|
"model": "qwen3-coder-70b",
|
||||||
|
"reasoning": false,
|
||||||
|
"attachment": true,
|
||||||
|
"localModelServerId": server_id.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
|
||||||
|
let input: CloneOpenCodeProfileFromSeedInput = dto.into();
|
||||||
|
assert_eq!(input.name.as_deref(), Some("Local Qwen"));
|
||||||
|
let opencode = input.opencode.expect("config override");
|
||||||
|
assert_eq!(opencode.base_url, "http://localhost:8081/v1");
|
||||||
|
assert_eq!(opencode.api_key, None);
|
||||||
|
assert_eq!(opencode.model, "qwen3-coder-70b");
|
||||||
|
assert_eq!(opencode.reasoning, Some(false));
|
||||||
|
assert_eq!(opencode.attachment, Some(true));
|
||||||
|
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
||||||
|
let config = OpenCodeConfig::new(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&config).unwrap();
|
||||||
|
assert!(value.get("localModelServerId").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn configure_request_deserialises_profiles() {
|
fn configure_request_deserialises_profiles() {
|
||||||
let raw = json!({ "profiles": [] });
|
let raw = json!({ "profiles": [] });
|
||||||
|
|||||||
@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
|
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
|
||||||
use crate::project::project_context_path;
|
use crate::project::project_context_path;
|
||||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||||
use crate::workstate::GetLiveStateLean;
|
use crate::workstate::GetLiveStateLean;
|
||||||
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
|
|||||||
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
|
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
|
||||||
/// parse ⇒ section omise, jamais d'échec de lancement.
|
/// parse ⇒ section omise, jamais d'échec de lancement.
|
||||||
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
|
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
|
||||||
|
/// Ensure local model servers referenced by OpenCode profiles before writing
|
||||||
|
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
|
||||||
|
/// when `OpenCodeConfig.localModelServerId` is set.
|
||||||
|
local_model_server: Option<Arc<EnsureLocalModelServer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LaunchAgent {
|
impl LaunchAgent {
|
||||||
@ -1174,9 +1179,17 @@ impl LaunchAgent {
|
|||||||
provider_sessions: None,
|
provider_sessions: None,
|
||||||
projectors: None,
|
projectors: None,
|
||||||
live_state_lean: None,
|
live_state_lean: None,
|
||||||
|
local_model_server: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||||
|
self.local_model_server = Some(ensure);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
||||||
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
||||||
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
||||||
@ -1550,7 +1563,7 @@ impl LaunchAgent {
|
|||||||
.contexts
|
.contexts
|
||||||
.read_context(&input.project, &agent.id)
|
.read_context(&input.project, &agent.id)
|
||||||
.await?;
|
.await?;
|
||||||
let profile = self
|
let mut profile = self
|
||||||
.profiles
|
.profiles
|
||||||
.list()
|
.list()
|
||||||
.await?
|
.await?
|
||||||
@ -1649,6 +1662,9 @@ impl LaunchAgent {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||||
@ -2336,6 +2352,60 @@ impl LaunchAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_local_model_server_for_opencode(
|
||||||
|
&self,
|
||||||
|
agent: &Agent,
|
||||||
|
profile: &mut AgentProfile,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(opencode) = profile.opencode.as_mut() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(server_id) = opencode.local_model_server_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(ensure) = self.local_model_server.as_ref() else {
|
||||||
|
let err = AppError::ModelServer {
|
||||||
|
code: "not_wired".to_owned(),
|
||||||
|
message:
|
||||||
|
"OpenCode profile references localModelServerId but no model server service is wired"
|
||||||
|
.to_owned(),
|
||||||
|
};
|
||||||
|
self.publish_agent_launch_failed(agent.id, &err);
|
||||||
|
return Err(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
match ensure
|
||||||
|
.execute(EnsureLocalModelServerInput { server_id })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => {
|
||||||
|
opencode.base_url = output.ready.base_url;
|
||||||
|
opencode.model = output.ready.model;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
self.publish_agent_launch_failed(agent.id, &err);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
|
||||||
|
let (cause, code) = match err {
|
||||||
|
AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
|
||||||
|
_ => ("launch", err.code()),
|
||||||
|
};
|
||||||
|
self.events.publish(DomainEvent::AgentLaunchFailed {
|
||||||
|
agent_id,
|
||||||
|
cause: cause.to_owned(),
|
||||||
|
code: code.to_owned(),
|
||||||
|
message: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||||||
|
|||||||
@ -41,8 +41,10 @@ pub use resume::{
|
|||||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||||
};
|
};
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||||
|
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||||
|
SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,12 +13,13 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{AgentRuntime, ProfileStore};
|
use domain::ids::ProfileId;
|
||||||
use domain::profile::AgentProfile;
|
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
|
||||||
|
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
use super::catalogue::selectable_reference_profiles;
|
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DetectProfiles
|
// DetectProfiles
|
||||||
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
|
|||||||
pub profile: AgentProfile,
|
pub profile: AgentProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CloneOpenCodeProfileFromSeed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Input for [`CloneOpenCodeProfileFromSeed::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CloneOpenCodeProfileFromSeedInput {
|
||||||
|
/// Optional display name for the cloned profile. When absent, a copy label is
|
||||||
|
/// derived from the seed name.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Optional OpenCode config override. When absent, the seed config is copied.
|
||||||
|
pub opencode: Option<OpenCodeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`CloneOpenCodeProfileFromSeed::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CloneOpenCodeProfileFromSeedOutput {
|
||||||
|
/// The newly persisted profile.
|
||||||
|
pub profile: AgentProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new OpenCode profile instance from the canonical seed/template.
|
||||||
|
///
|
||||||
|
/// The persisted canonical `opencode-llamacpp` profile is preferred when present,
|
||||||
|
/// so local edits are preserved as the clone template. If it is absent, the
|
||||||
|
/// in-memory reference catalogue seed is used. The new profile always receives a
|
||||||
|
/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles
|
||||||
|
/// with `StructuredAdapter::OpenCode` are therefore valid.
|
||||||
|
pub struct CloneOpenCodeProfileFromSeed {
|
||||||
|
store: Arc<dyn ProfileStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CloneOpenCodeProfileFromSeed {
|
||||||
|
/// Builds the use case from the profile store and id generator ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||||
|
Self { store, ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clones the canonical OpenCode seed into a new persisted profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the
|
||||||
|
/// requested name is blank, or [`AppError::Internal`] if the seed is malformed.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: CloneOpenCodeProfileFromSeedInput,
|
||||||
|
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
|
||||||
|
let existing = self.store.list().await?;
|
||||||
|
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||||
|
let seed = existing
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.id == seed_id)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| {
|
||||||
|
reference_profiles()
|
||||||
|
.into_iter()
|
||||||
|
.find(|profile| profile.id == seed_id)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() {
|
||||||
|
return Err(AppError::Internal(
|
||||||
|
"canonical OpenCode seed is not an OpenCode profile".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut profile = seed;
|
||||||
|
profile.id = fresh_profile_id(&*self.ids, &existing)?;
|
||||||
|
profile.name = match input.name {
|
||||||
|
Some(name) => {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(AppError::Invalid("profile.name must not be empty".into()));
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
None => format!("{} copy", profile.name),
|
||||||
|
};
|
||||||
|
if let Some(config) = input.opencode {
|
||||||
|
profile.opencode = Some(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.store.save(&profile).await?;
|
||||||
|
Ok(CloneOpenCodeProfileFromSeedOutput { profile })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_profile_id(
|
||||||
|
ids: &dyn IdGenerator,
|
||||||
|
existing: &[AgentProfile],
|
||||||
|
) -> Result<ProfileId, AppError> {
|
||||||
|
for _ in 0..16 {
|
||||||
|
let id = ProfileId::from_uuid(ids.new_uuid());
|
||||||
|
if existing.iter().all(|profile| profile.id != id) {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AppError::Internal(
|
||||||
|
"could not allocate a unique profile id".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Persists (creates or replaces) a single profile.
|
/// Persists (creates or replaces) a single profile.
|
||||||
pub struct SaveProfile {
|
pub struct SaveProfile {
|
||||||
store: Arc<dyn ProfileStore>,
|
store: Arc<dyn ProfileStore>,
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
//! with one error shape when building its `ErrorDTO`.
|
//! with one error shape when building its `ErrorDTO`.
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||||
RemoteError, RuntimeError, StoreError,
|
ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, NodeId};
|
use domain::{AgentId, NodeId};
|
||||||
use domain::{IssueStoreError, SprintStoreError};
|
use domain::{IssueStoreError, SprintStoreError};
|
||||||
@ -38,6 +38,15 @@ pub enum AppError {
|
|||||||
#[error("process error: {0}")]
|
#[error("process error: {0}")]
|
||||||
Process(String),
|
Process(String),
|
||||||
|
|
||||||
|
/// A local model server could not be prepared for launch.
|
||||||
|
#[error("model server error ({code}): {message}")]
|
||||||
|
ModelServer {
|
||||||
|
/// Stable model-server error code.
|
||||||
|
code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// A git operation failed.
|
/// A git operation failed.
|
||||||
#[error("git error: {0}")]
|
#[error("git error: {0}")]
|
||||||
Git(String),
|
Git(String),
|
||||||
@ -101,6 +110,7 @@ impl AppError {
|
|||||||
Self::FileSystem(_) => "FILESYSTEM",
|
Self::FileSystem(_) => "FILESYSTEM",
|
||||||
Self::Store(_) => "STORE",
|
Self::Store(_) => "STORE",
|
||||||
Self::Process(_) => "PROCESS",
|
Self::Process(_) => "PROCESS",
|
||||||
|
Self::ModelServer { .. } => "MODEL_SERVER",
|
||||||
Self::Git(_) => "GIT",
|
Self::Git(_) => "GIT",
|
||||||
Self::Remote(_) => "REMOTE",
|
Self::Remote(_) => "REMOTE",
|
||||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||||
@ -183,6 +193,30 @@ impl From<ProcessError> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ModelServerError> for AppError {
|
||||||
|
fn from(e: ModelServerError) -> Self {
|
||||||
|
Self::ModelServer {
|
||||||
|
code: model_server_error_code(&e).to_owned(),
|
||||||
|
message: e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||||
|
match err {
|
||||||
|
ModelServerError::NotConfigured => "not_configured",
|
||||||
|
ModelServerError::Invalid(_) => "invalid",
|
||||||
|
ModelServerError::PermissionDenied(_) => "permission_denied",
|
||||||
|
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
||||||
|
ModelServerError::PortOccupied(_) => "port_occupied",
|
||||||
|
ModelServerError::InUse(_) => "model_server_in_use",
|
||||||
|
ModelServerError::Probe(_) => "probe",
|
||||||
|
ModelServerError::Process(_) => "process",
|
||||||
|
ModelServerError::Store(_) => "store",
|
||||||
|
ModelServerError::Timeout => "timeout",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<RuntimeError> for AppError {
|
impl From<RuntimeError> for AppError {
|
||||||
fn from(e: RuntimeError) -> Self {
|
fn from(e: RuntimeError) -> Self {
|
||||||
Self::Process(e.to_string())
|
Self::Process(e.to_string())
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub mod health;
|
|||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@ -39,19 +40,20 @@ pub use agent::{
|
|||||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||||
FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
|
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles,
|
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
||||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
||||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
||||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use background::{
|
pub use background::{
|
||||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||||
@ -102,6 +104,12 @@ pub use memory::{
|
|||||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||||
};
|
};
|
||||||
|
pub use model_server::{
|
||||||
|
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||||
|
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||||
|
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||||
|
SaveModelServerInput, SaveModelServerOutput,
|
||||||
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||||
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
||||||
|
|||||||
447
crates/application/src/model_server.rs
Normal file
447
crates/application/src/model_server.rs
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
//! Use cases for local model servers.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::model_server::{
|
||||||
|
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||||
|
};
|
||||||
|
use domain::ports::{
|
||||||
|
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||||
|
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Output of [`ListModelServers::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListModelServersOutput {
|
||||||
|
/// Persisted local model-server configs.
|
||||||
|
pub servers: Vec<LocalModelServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists local model-server configurations.
|
||||||
|
pub struct ListModelServers {
|
||||||
|
registry: Arc<dyn ModelServerRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListModelServers {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||||
|
Self { registry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists configs.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::ModelServer`] on registry failure.
|
||||||
|
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
|
||||||
|
Ok(ListModelServersOutput {
|
||||||
|
servers: self.registry.list().await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`SaveModelServer::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SaveModelServerInput {
|
||||||
|
/// Config to upsert by id.
|
||||||
|
pub config: LocalModelServerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`SaveModelServer::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SaveModelServerOutput {
|
||||||
|
/// Saved config.
|
||||||
|
pub config: LocalModelServerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a local model-server configuration.
|
||||||
|
pub struct SaveModelServer {
|
||||||
|
registry: Arc<dyn ModelServerRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveModelServer {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||||
|
Self { registry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a config.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::ModelServer`] on registry failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: SaveModelServerInput,
|
||||||
|
) -> Result<SaveModelServerOutput, AppError> {
|
||||||
|
self.registry.save(input.config.clone()).await?;
|
||||||
|
Ok(SaveModelServerOutput {
|
||||||
|
config: input.config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`DeleteModelServer::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteModelServerInput {
|
||||||
|
/// Config id to delete.
|
||||||
|
pub server_id: LocalModelServerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a local model-server config when no OpenCode profile still references it.
|
||||||
|
pub struct DeleteModelServer {
|
||||||
|
registry: Arc<dyn ModelServerRegistry>,
|
||||||
|
profiles: Arc<dyn ProfileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteModelServer {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(registry: Arc<dyn ModelServerRegistry>, profiles: Arc<dyn ProfileStore>) -> Self {
|
||||||
|
Self { registry, profiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a config after checking profile references.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced.
|
||||||
|
pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> {
|
||||||
|
let profiles = self.profiles.list().await?;
|
||||||
|
if profiles.iter().any(|profile| {
|
||||||
|
profile
|
||||||
|
.opencode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| config.local_model_server_id)
|
||||||
|
== Some(input.server_id)
|
||||||
|
}) {
|
||||||
|
return Err(ModelServerError::InUse(input.server_id.to_string()).into());
|
||||||
|
}
|
||||||
|
self.registry.delete(input.server_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`EnsureLocalModelServer::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EnsureLocalModelServerInput {
|
||||||
|
/// Referenced local model server.
|
||||||
|
pub server_id: LocalModelServerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`EnsureLocalModelServer::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EnsureLocalModelServerOutput {
|
||||||
|
/// Ready server data to inject into OpenCode config.
|
||||||
|
pub ready: ModelServerReady,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Readiness retry policy.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ReadinessPolicy {
|
||||||
|
/// Number of probes after spawning.
|
||||||
|
pub attempts: usize,
|
||||||
|
/// Delay between attempts.
|
||||||
|
pub backoff: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ReadinessPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
attempts: 20,
|
||||||
|
backoff: Duration::from_millis(250),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ActiveServer {
|
||||||
|
handle: ManagedProcessHandle,
|
||||||
|
port: u16,
|
||||||
|
stop_policy: StopPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures a configured local model server is reachable, starting it when allowed.
|
||||||
|
pub struct EnsureLocalModelServer {
|
||||||
|
registry: Arc<dyn ModelServerRegistry>,
|
||||||
|
probe: Arc<dyn ModelServerProbe>,
|
||||||
|
process: Arc<dyn ManagedProcess>,
|
||||||
|
runtime: Arc<dyn ModelServerRuntime>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
||||||
|
readiness: ReadinessPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnsureLocalModelServer {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
registry: Arc<dyn ModelServerRegistry>,
|
||||||
|
probe: Arc<dyn ModelServerProbe>,
|
||||||
|
process: Arc<dyn ManagedProcess>,
|
||||||
|
runtime: Arc<dyn ModelServerRuntime>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
registry,
|
||||||
|
probe,
|
||||||
|
process,
|
||||||
|
runtime,
|
||||||
|
fs,
|
||||||
|
events,
|
||||||
|
active: Mutex::new(HashMap::new()),
|
||||||
|
readiness: ReadinessPolicy::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides readiness policy, mainly for tests.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self {
|
||||||
|
self.readiness = readiness;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures the server is reachable.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::ModelServer`] if the server cannot be prepared.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: EnsureLocalModelServerInput,
|
||||||
|
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||||
|
let config = self
|
||||||
|
.registry
|
||||||
|
.get(&input.server_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ModelServerError::NotConfigured)?;
|
||||||
|
|
||||||
|
self.publish(config.id, ModelServerLifecycleStatus::Probing);
|
||||||
|
let initial_probe = match self.probe.probe(&config.endpoint).await {
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(err) => return self.fail(config.id, err),
|
||||||
|
};
|
||||||
|
match initial_probe {
|
||||||
|
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
||||||
|
self.publish(
|
||||||
|
config.id,
|
||||||
|
ModelServerLifecycleStatus::Ready { reused: true },
|
||||||
|
);
|
||||||
|
return Ok(EnsureLocalModelServerOutput {
|
||||||
|
ready: ready(&config, ModelServerStatus::ReadyReused),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ModelServerStatus::Unreachable => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.auto_start {
|
||||||
|
let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned());
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_model_path_accessible(&config).await?;
|
||||||
|
self.ensure_no_active_port_collision(&config).await?;
|
||||||
|
|
||||||
|
self.publish(config.id, ModelServerLifecycleStatus::Starting);
|
||||||
|
let spec = match self.runtime.build_spawn_spec(&config) {
|
||||||
|
Ok(spec) => spec,
|
||||||
|
Err(err) => return self.fail(config.id, err),
|
||||||
|
};
|
||||||
|
let handle = match self.process.spawn(spec).await {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(err) => return self.fail(config.id, err),
|
||||||
|
};
|
||||||
|
self.active.lock().unwrap().insert(
|
||||||
|
config.id,
|
||||||
|
ActiveServer {
|
||||||
|
handle: handle.clone(),
|
||||||
|
port: config.endpoint.port,
|
||||||
|
stop_policy: config.stop_policy,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for attempt in 0..self.readiness.attempts {
|
||||||
|
match self.probe.probe(&config.endpoint).await {
|
||||||
|
Err(err) => {
|
||||||
|
self.stop_started_server(config.id, &handle).await;
|
||||||
|
return self.fail(config.id, err);
|
||||||
|
}
|
||||||
|
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
|
||||||
|
self.publish(
|
||||||
|
config.id,
|
||||||
|
ModelServerLifecycleStatus::Ready { reused: false },
|
||||||
|
);
|
||||||
|
return Ok(EnsureLocalModelServerOutput {
|
||||||
|
ready: ready(&config, ModelServerStatus::ReadyStarted),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ModelServerStatus::Unreachable) => {
|
||||||
|
if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() {
|
||||||
|
tokio::time::sleep(self.readiness.backoff).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = ModelServerError::Timeout;
|
||||||
|
self.stop_started_server(config.id, &handle).await;
|
||||||
|
self.fail(config.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns the first process error after attempting every eligible stop.
|
||||||
|
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
|
||||||
|
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
|
||||||
|
.active
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, active)| (*id, active.clone()))
|
||||||
|
.collect();
|
||||||
|
let mut first_error: Option<ModelServerError> = None;
|
||||||
|
for (id, active) in entries {
|
||||||
|
if active.stop_policy != StopPolicy::StopOnAppExit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = self.process.kill(&active.handle).await {
|
||||||
|
if first_error.is_none() {
|
||||||
|
first_error = Some(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.active.lock().unwrap().remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(err) = first_error {
|
||||||
|
Err(err.into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_model_path_accessible(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Some(path) = config.model.path.as_ref() else {
|
||||||
|
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
return Err(err.into());
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.fs
|
||||||
|
.exists(&RemotePath::new(path.as_str().to_owned()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => Ok(()),
|
||||||
|
Ok(false) => {
|
||||||
|
let err = ModelServerError::PathNotAccessible(path.as_str().to_owned());
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||||
|
let err = ModelServerError::PermissionDenied(p);
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let err = ModelServerError::PathNotAccessible(err.to_string());
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_no_active_port_collision(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let active: Vec<(LocalModelServerId, ActiveServer)> = self
|
||||||
|
.active
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, active)| (*id, active.clone()))
|
||||||
|
.collect();
|
||||||
|
for (id, active) in active {
|
||||||
|
if active.port != config.endpoint.port {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.process.status(&active.handle).await {
|
||||||
|
Ok(ProcessStatus::Running) => {
|
||||||
|
let err = ModelServerError::PortOccupied(config.endpoint.port);
|
||||||
|
self.publish_failure(config.id, &err);
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => {
|
||||||
|
self.active.lock().unwrap().remove(&id);
|
||||||
|
}
|
||||||
|
Err(err) => return self.fail(config.id, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_started_server(
|
||||||
|
&self,
|
||||||
|
server_id: LocalModelServerId,
|
||||||
|
handle: &ManagedProcessHandle,
|
||||||
|
) {
|
||||||
|
let _ = self.process.kill(handle).await;
|
||||||
|
self.active.lock().unwrap().remove(&server_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) {
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::ModelServerStatusChanged { server_id, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail<T>(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result<T, AppError> {
|
||||||
|
self.publish_failure(server_id, &err);
|
||||||
|
Err(err.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) {
|
||||||
|
self.publish(
|
||||||
|
server_id,
|
||||||
|
ModelServerLifecycleStatus::Failed {
|
||||||
|
code: model_server_error_code(err).to_owned(),
|
||||||
|
message: err.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
|
||||||
|
ModelServerReady {
|
||||||
|
base_url: config.endpoint.base_url.clone(),
|
||||||
|
model: config.model.served_name.clone(),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable model-server error code for event/DTO mapping.
|
||||||
|
#[must_use]
|
||||||
|
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||||
|
match err {
|
||||||
|
ModelServerError::NotConfigured => "not_configured",
|
||||||
|
ModelServerError::Invalid(_) => "invalid",
|
||||||
|
ModelServerError::PermissionDenied(_) => "permission_denied",
|
||||||
|
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
||||||
|
ModelServerError::PortOccupied(_) => "port_occupied",
|
||||||
|
ModelServerError::InUse(_) => "model_server_in_use",
|
||||||
|
ModelServerError::Probe(_) => "probe",
|
||||||
|
ModelServerError::Process(_) => "process",
|
||||||
|
ModelServerError::Store(_) => "store",
|
||||||
|
ModelServerError::Timeout => "timeout",
|
||||||
|
}
|
||||||
|
}
|
||||||
512
crates/application/tests/model_server.rs
Normal file
512
crates/application/tests/model_server.rs
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
//! Unit tests for the local model-server ensure use case.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||||
|
ModelServerReadinessPolicy,
|
||||||
|
};
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::model_server::{
|
||||||
|
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||||
|
ModelServerEndpoint, ModelServerStatus, StopPolicy,
|
||||||
|
};
|
||||||
|
use domain::ports::{
|
||||||
|
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||||
|
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||||
|
ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||||
|
};
|
||||||
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
|
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||||
|
|
||||||
|
fn sid(n: u128) -> LocalModelServerId {
|
||||||
|
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(
|
||||||
|
id: LocalModelServerId,
|
||||||
|
port: u16,
|
||||||
|
path: &str,
|
||||||
|
auto_start: bool,
|
||||||
|
) -> LocalModelServerConfig {
|
||||||
|
LocalModelServerConfig::new(
|
||||||
|
id,
|
||||||
|
LocalModelServerKind::LlamaCpp,
|
||||||
|
"llama.cpp",
|
||||||
|
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
|
||||||
|
LocalModelRef::new(
|
||||||
|
"qwen",
|
||||||
|
"Qwen",
|
||||||
|
Some(ModelPath::new(path).unwrap()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some(ExecutablePath::new("llama-server").unwrap()),
|
||||||
|
Vec::new(),
|
||||||
|
auto_start,
|
||||||
|
StopPolicy::StopOnAppExit,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelServerRegistry for FakeRegistry {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
id: &LocalModelServerId,
|
||||||
|
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||||
|
Ok(self.0.lock().unwrap().get(id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||||
|
Ok(self.0.lock().unwrap().values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||||
|
self.0.lock().unwrap().insert(config.id, config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||||
|
self.0.lock().unwrap().remove(&id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeProfiles(Mutex<Vec<AgentProfile>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProfileStore for FakeProfiles {
|
||||||
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||||
|
Ok(self.0.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||||
|
self.0.lock().unwrap().push(profile.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opencode_profile(id: u128, server_id: LocalModelServerId) -> AgentProfile {
|
||||||
|
AgentProfile::new(
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||||
|
"Local OpenCode",
|
||||||
|
"opencode",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::stdin(),
|
||||||
|
None,
|
||||||
|
"{projectRoot}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||||
|
.with_opencode(
|
||||||
|
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen", None, None)
|
||||||
|
.unwrap()
|
||||||
|
.with_local_model_server_id(server_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeProbe(Mutex<VecDeque<ModelServerStatus>>);
|
||||||
|
|
||||||
|
impl FakeProbe {
|
||||||
|
fn new(statuses: Vec<ModelServerStatus>) -> Self {
|
||||||
|
Self(Mutex::new(statuses.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelServerProbe for FakeProbe {
|
||||||
|
async fn probe(
|
||||||
|
&self,
|
||||||
|
_endpoint: &ModelServerEndpoint,
|
||||||
|
) -> Result<ModelServerStatus, ModelServerError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or(ModelServerStatus::Unreachable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeProcess {
|
||||||
|
spawns: Mutex<Vec<SpawnSpec>>,
|
||||||
|
kills: Mutex<Vec<String>>,
|
||||||
|
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ManagedProcess for FakeProcess {
|
||||||
|
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||||
|
self.spawns.lock().unwrap().push(spec);
|
||||||
|
let handle = ManagedProcessHandle {
|
||||||
|
id: format!("h{}", self.spawns.lock().unwrap().len()),
|
||||||
|
};
|
||||||
|
self.statuses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(handle.id.clone(), ProcessStatus::Running);
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||||
|
self.kills.lock().unwrap().push(handle.id.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(
|
||||||
|
&self,
|
||||||
|
handle: &ManagedProcessHandle,
|
||||||
|
) -> Result<ProcessStatus, ModelServerError> {
|
||||||
|
Ok(*self
|
||||||
|
.statuses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&handle.id)
|
||||||
|
.unwrap_or(&ProcessStatus::Unknown))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeRuntime;
|
||||||
|
|
||||||
|
impl ModelServerRuntime for FakeRuntime {
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
Ok(SpawnSpec {
|
||||||
|
command: config
|
||||||
|
.binary
|
||||||
|
.as_ref()
|
||||||
|
.map(|binary| binary.as_str().to_owned())
|
||||||
|
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||||
|
args: vec![
|
||||||
|
"--model".to_owned(),
|
||||||
|
config.model.path.as_ref().unwrap().as_str().to_owned(),
|
||||||
|
"--port".to_owned(),
|
||||||
|
config.endpoint.port.to_string(),
|
||||||
|
],
|
||||||
|
cwd: ProjectPath::new("/").unwrap(),
|
||||||
|
env: Vec::new(),
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeFs {
|
||||||
|
existing: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileSystem for FakeFs {
|
||||||
|
async fn read(&self, _path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||||
|
Err(FsError::NotFound("unused".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||||
|
Ok(self
|
||||||
|
.existing
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|p| p == path.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeEvents(Mutex<Vec<DomainEvent>>);
|
||||||
|
|
||||||
|
impl EventBus for FakeEvents {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.0.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure(
|
||||||
|
registry: Arc<FakeRegistry>,
|
||||||
|
probe: Arc<FakeProbe>,
|
||||||
|
process: Arc<FakeProcess>,
|
||||||
|
fs: Arc<FakeFs>,
|
||||||
|
events: Arc<FakeEvents>,
|
||||||
|
) -> EnsureLocalModelServer {
|
||||||
|
EnsureLocalModelServer::new(registry, probe, process, Arc::new(FakeRuntime), fs, events)
|
||||||
|
.with_readiness_policy(ModelServerReadinessPolicy {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: Duration::ZERO,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reachable_server_is_reused_without_spawn() {
|
||||||
|
let registry = Arc::new(FakeRegistry::default());
|
||||||
|
registry
|
||||||
|
.save(config(sid(1), 8080, "/models/qwen.gguf", true))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let process = Arc::new(FakeProcess::default());
|
||||||
|
let usecase = ensure(
|
||||||
|
Arc::clone(®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<dyn ModelServerRegistry>,
|
||||||
|
profiles,
|
||||||
|
);
|
||||||
|
|
||||||
|
usecase
|
||||||
|
.execute(DeleteModelServerInput { server_id: sid(9) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||||
|
}
|
||||||
@ -13,15 +13,17 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use domain::ids::ProfileId;
|
use domain::ids::ProfileId;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
|
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
|
||||||
|
StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
||||||
|
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
|
||||||
|
|
||||||
|
impl SeqIds {
|
||||||
|
fn new(ids: Vec<uuid::Uuid>) -> Self {
|
||||||
|
Self(Mutex::new(ids))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdGenerator for SeqIds {
|
||||||
|
fn new_uuid(&self) -> uuid::Uuid {
|
||||||
|
self.0.lock().unwrap().remove(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||||
@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() {
|
|||||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||||
|
Arc::new(store.clone()),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = clone
|
||||||
|
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||||
|
name: Some("OpenCode llama.cpp 2".to_owned()),
|
||||||
|
opencode: Some(
|
||||||
|
OpenCodeConfig::new(
|
||||||
|
"http://localhost:8081/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-70b",
|
||||||
|
Some(false),
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||||
|
assert_ne!(out.profile.id, seed_id);
|
||||||
|
assert_eq!(
|
||||||
|
out.profile.id,
|
||||||
|
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
|
||||||
|
);
|
||||||
|
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
|
||||||
|
assert_eq!(
|
||||||
|
out.profile.structured_adapter,
|
||||||
|
Some(StructuredAdapter::OpenCode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out.profile
|
||||||
|
.opencode
|
||||||
|
.as_ref()
|
||||||
|
.map(|config| config.base_url.as_str()),
|
||||||
|
Some("http://localhost:8081/v1")
|
||||||
|
);
|
||||||
|
|
||||||
|
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||||
|
assert_eq!(profiles.len(), 1);
|
||||||
|
assert_eq!(profiles[0].id, out.profile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
|
||||||
|
let store = FakeProfileStore::default();
|
||||||
|
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||||
|
let mut seed = reference_profiles()
|
||||||
|
.into_iter()
|
||||||
|
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||||
|
.expect("seed exists");
|
||||||
|
seed.name = "Edited local OpenCode seed".to_owned();
|
||||||
|
save.execute(SaveProfileInput {
|
||||||
|
profile: seed.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||||
|
Arc::new(store.clone()),
|
||||||
|
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
|
||||||
|
);
|
||||||
|
let out = clone
|
||||||
|
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||||
|
name: None,
|
||||||
|
opencode: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
|
||||||
|
assert_eq!(out.profile.opencode, seed.opencode);
|
||||||
|
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||||
|
assert_eq!(
|
||||||
|
profiles
|
||||||
|
.iter()
|
||||||
|
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"the canonical seed must be preserved, not recreated as a duplicate"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
profiles
|
||||||
|
.iter()
|
||||||
|
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
|
||||||
|
.count(),
|
||||||
|
2,
|
||||||
|
"ProfileId is the identity: multiple OpenCode profiles can coexist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ReferenceProfiles / catalogue
|
// ReferenceProfiles / catalogue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::ConversationParty;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId,
|
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
||||||
|
TaskId, TemplateId,
|
||||||
};
|
};
|
||||||
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
@ -46,6 +47,24 @@ pub enum DomainEvent {
|
|||||||
/// The session it runs in.
|
/// The session it runs in.
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
},
|
},
|
||||||
|
/// An agent launch failed before a runtime session could be created.
|
||||||
|
AgentLaunchFailed {
|
||||||
|
/// The agent whose launch failed.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// Failure cause namespace.
|
||||||
|
cause: String,
|
||||||
|
/// Stable error code.
|
||||||
|
code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// A local model-server status changed while preparing a launch.
|
||||||
|
ModelServerStatusChanged {
|
||||||
|
/// The local model server.
|
||||||
|
server_id: LocalModelServerId,
|
||||||
|
/// New lifecycle status.
|
||||||
|
status: crate::model_server::ModelServerLifecycleStatus,
|
||||||
|
},
|
||||||
/// A first-class background task started running.
|
/// A first-class background task started running.
|
||||||
BackgroundTaskStarted {
|
BackgroundTaskStarted {
|
||||||
/// The owning project.
|
/// The owning project.
|
||||||
|
|||||||
@ -68,6 +68,10 @@ typed_id!(
|
|||||||
/// Identifies an [`crate::profile::AgentProfile`].
|
/// Identifies an [`crate::profile::AgentProfile`].
|
||||||
ProfileId
|
ProfileId
|
||||||
);
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifies a local model server configuration.
|
||||||
|
LocalModelServerId
|
||||||
|
);
|
||||||
typed_id!(
|
typed_id!(
|
||||||
/// Identifies a [`crate::skill::Skill`].
|
/// Identifies a [`crate::skill::Skill`].
|
||||||
SkillId
|
SkillId
|
||||||
|
|||||||
@ -49,6 +49,7 @@ pub mod mailbox;
|
|||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_harvest;
|
pub mod memory_harvest;
|
||||||
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
@ -72,8 +73,8 @@ mod validation;
|
|||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId,
|
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
|
||||||
SprintId, TabId, TaskId, TemplateId, WindowId,
|
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use project::{Project, ProjectPath};
|
pub use project::{Project, ProjectPath};
|
||||||
@ -153,6 +154,12 @@ pub use memory_harvest::{
|
|||||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use model_server::{
|
||||||
|
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||||
|
ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
|
||||||
|
StopPolicy,
|
||||||
|
};
|
||||||
|
|
||||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||||
|
|
||||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||||
|
|||||||
368
crates/domain/src/model_server.rs
Normal file
368
crates/domain/src/model_server.rs
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
//! Local model-server configuration and status.
|
||||||
|
//!
|
||||||
|
//! These value objects describe a local OpenAI-compatible server that can back an
|
||||||
|
//! OpenCode profile. They are pure persisted configuration: probing, process
|
||||||
|
//! spawning and filesystem checks live behind ports.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::DomainError;
|
||||||
|
use crate::ids::LocalModelServerId;
|
||||||
|
|
||||||
|
/// Local model server implementation family.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum LocalModelServerKind {
|
||||||
|
/// `llama-server` from llama.cpp.
|
||||||
|
LlamaCpp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP endpoint for an OpenAI-compatible model server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelServerEndpoint {
|
||||||
|
/// Normalised base URL, always ending in `/v1`.
|
||||||
|
#[serde(rename = "baseURL")]
|
||||||
|
pub base_url: String,
|
||||||
|
/// TCP port expected for the local server.
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelServerEndpoint {
|
||||||
|
/// Builds a validated endpoint and normalises its base URL to `/v1`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if the URL is blank, non-HTTP(S), or the port is 0.
|
||||||
|
pub fn new(base_url: impl Into<String>, port: u16) -> Result<Self, DomainError> {
|
||||||
|
let base_url = normalise_base_url(base_url.into())?;
|
||||||
|
if port == 0 {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.endpoint.port must be in 1..=65535".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { base_url, port })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalise_base_url(raw: String) -> Result<String, DomainError> {
|
||||||
|
let trimmed = raw.trim().trim_end_matches('/').to_owned();
|
||||||
|
crate::validation::non_empty(&trimmed, "modelServer.endpoint.baseURL")?;
|
||||||
|
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.endpoint.baseURL must start with http:// or https://".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !has_local_host_authority(&trimmed) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.endpoint.baseURL must target localhost in V1".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if trimmed.ends_with("/v1") {
|
||||||
|
Ok(trimmed)
|
||||||
|
} else {
|
||||||
|
Ok(format!("{trimmed}/v1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_local_host_authority(url: &str) -> bool {
|
||||||
|
let Some(authority_and_path) = url.split_once("://").map(|(_, rest)| rest) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let authority = authority_and_path.split('/').next().unwrap_or_default();
|
||||||
|
let host = authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, host)| host)
|
||||||
|
.unwrap_or(authority);
|
||||||
|
let host = host
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|ipv6| ipv6.split_once(']').map(|(addr, _)| addr))
|
||||||
|
.unwrap_or_else(|| host.split(':').next().unwrap_or_default());
|
||||||
|
host.eq_ignore_ascii_case("localhost") || host == "::1" || is_ipv4_loopback(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ipv4_loopback(host: &str) -> bool {
|
||||||
|
host.parse::<Ipv4Addr>()
|
||||||
|
.map(|addr| addr.octets()[0] == 127)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute local path to a model file.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ModelPath(pub String);
|
||||||
|
|
||||||
|
impl ModelPath {
|
||||||
|
/// Builds a validated absolute model path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if the path is blank or relative.
|
||||||
|
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let path = path.into();
|
||||||
|
crate::validation::non_empty(&path, "modelServer.model.path")?;
|
||||||
|
if !is_absolute_path(&path) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.model.path must be absolute".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute local path to an executable.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ExecutablePath(pub String);
|
||||||
|
|
||||||
|
impl ExecutablePath {
|
||||||
|
/// Builds a validated executable path.
|
||||||
|
///
|
||||||
|
/// Absolute paths are accepted directly. Bare command names are accepted so
|
||||||
|
/// infrastructure can resolve them through `PATH` without shell interpolation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if the path is blank.
|
||||||
|
pub fn new(path: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let path = path.into();
|
||||||
|
crate::validation::non_empty(&path, "modelServer.binary")?;
|
||||||
|
Ok(Self(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path as a string slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_absolute_path(path: &str) -> bool {
|
||||||
|
path.starts_with('/')
|
||||||
|
|| path.starts_with('\\')
|
||||||
|
|| (path.len() >= 3
|
||||||
|
&& path.as_bytes()[1] == b':'
|
||||||
|
&& path.as_bytes()[0].is_ascii_alphabetic()
|
||||||
|
&& matches!(path.as_bytes()[2], b'/' | b'\\'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model served by a local model server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalModelRef {
|
||||||
|
/// Stable model id in IdeA configuration.
|
||||||
|
pub id: String,
|
||||||
|
/// Human display label.
|
||||||
|
pub label: String,
|
||||||
|
/// Explicit absolute `.gguf` path. Required for auto-start.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<ModelPath>,
|
||||||
|
/// Name exposed by the server and used in `OpenCodeConfig.model`.
|
||||||
|
pub served_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalModelRef {
|
||||||
|
/// Builds a validated local model reference.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if `id`, `label` or `served_name` is blank.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<String>,
|
||||||
|
label: impl Into<String>,
|
||||||
|
path: Option<ModelPath>,
|
||||||
|
served_name: impl Into<String>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let id = id.into();
|
||||||
|
let label = label.into();
|
||||||
|
let served_name = served_name.into();
|
||||||
|
crate::validation::non_empty(&id, "modelServer.model.id")?;
|
||||||
|
crate::validation::non_empty(&label, "modelServer.model.label")?;
|
||||||
|
crate::validation::non_empty(&served_name, "modelServer.model.servedName")?;
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
served_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop policy for a managed local model server.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum StopPolicy {
|
||||||
|
/// Leave the process running.
|
||||||
|
KeepAlive,
|
||||||
|
/// Stop when no profile uses it. Not used aggressively in V1.
|
||||||
|
StopWhenUnused,
|
||||||
|
/// Stop on application shutdown.
|
||||||
|
StopOnAppExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted configuration for a local model server.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalModelServerConfig {
|
||||||
|
/// Stable identity.
|
||||||
|
pub id: LocalModelServerId,
|
||||||
|
/// Server implementation family.
|
||||||
|
pub kind: LocalModelServerKind,
|
||||||
|
/// Display name.
|
||||||
|
pub name: String,
|
||||||
|
/// OpenAI-compatible endpoint.
|
||||||
|
pub endpoint: ModelServerEndpoint,
|
||||||
|
/// Served model.
|
||||||
|
pub model: LocalModelRef,
|
||||||
|
/// Optional executable path or bare command name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binary: Option<ExecutablePath>,
|
||||||
|
/// Extra argv passed to the runtime.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Whether IdeA may start the server lazily.
|
||||||
|
pub auto_start: bool,
|
||||||
|
/// Process stop policy.
|
||||||
|
#[serde(default = "default_stop_policy")]
|
||||||
|
pub stop_policy: StopPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stop_policy() -> StopPolicy {
|
||||||
|
StopPolicy::StopOnAppExit
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalModelServerConfig {
|
||||||
|
/// Builds a validated local server configuration.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`DomainError`] if names are blank or auto-start invariants are not
|
||||||
|
/// satisfied by static data.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
id: LocalModelServerId,
|
||||||
|
kind: LocalModelServerKind,
|
||||||
|
name: impl Into<String>,
|
||||||
|
endpoint: ModelServerEndpoint,
|
||||||
|
model: LocalModelRef,
|
||||||
|
binary: Option<ExecutablePath>,
|
||||||
|
args: Vec<String>,
|
||||||
|
auto_start: bool,
|
||||||
|
stop_policy: StopPolicy,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let name = name.into();
|
||||||
|
crate::validation::non_empty(&name, "modelServer.name")?;
|
||||||
|
if auto_start {
|
||||||
|
if kind != LocalModelServerKind::LlamaCpp {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.autoStart requires kind=LlamaCpp".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if model.path.is_none() {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"modelServer.autoStart requires model.path".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
binary,
|
||||||
|
args,
|
||||||
|
auto_start,
|
||||||
|
stop_policy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observed model-server status.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ModelServerStatus {
|
||||||
|
/// The server is reachable and was already running.
|
||||||
|
ReadyReused,
|
||||||
|
/// The server is reachable after IdeA started it.
|
||||||
|
ReadyStarted,
|
||||||
|
/// The server is not reachable.
|
||||||
|
Unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful ensure result used by agent launch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ModelServerReady {
|
||||||
|
/// Base URL to use in OpenCode config.
|
||||||
|
pub base_url: String,
|
||||||
|
/// Served model name to use in OpenCode config.
|
||||||
|
pub model: String,
|
||||||
|
/// Whether the server was reused or started.
|
||||||
|
pub status: ModelServerStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle status emitted for presentation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ModelServerLifecycleStatus {
|
||||||
|
/// No local model server is configured for the profile.
|
||||||
|
NotConfigured,
|
||||||
|
/// A readiness probe is in progress.
|
||||||
|
Probing,
|
||||||
|
/// IdeA is starting the server.
|
||||||
|
Starting,
|
||||||
|
/// The server is ready.
|
||||||
|
Ready {
|
||||||
|
/// `true` when an already-running server was reused.
|
||||||
|
reused: bool,
|
||||||
|
},
|
||||||
|
/// The server failed to become ready.
|
||||||
|
Failed {
|
||||||
|
/// Stable error code.
|
||||||
|
code: String,
|
||||||
|
/// Human-readable message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_normalises_to_v1() {
|
||||||
|
let endpoint = ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap();
|
||||||
|
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||||
|
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1/", 8080).unwrap();
|
||||||
|
assert_eq!(endpoint.base_url, "http://localhost:8080/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_rejects_remote_host() {
|
||||||
|
let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("localhost"));
|
||||||
|
let err =
|
||||||
|
ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("localhost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_start_requires_model_path() {
|
||||||
|
let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap();
|
||||||
|
let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap();
|
||||||
|
let err = LocalModelServerConfig::new(
|
||||||
|
LocalModelServerId::from_uuid(uuid::Uuid::nil()),
|
||||||
|
LocalModelServerKind::LlamaCpp,
|
||||||
|
"llama.cpp",
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
None,
|
||||||
|
Vec::new(),
|
||||||
|
true,
|
||||||
|
StopPolicy::StopOnAppExit,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("model.path"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,12 +34,15 @@ use crate::background_task::{
|
|||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId};
|
use crate::ids::{
|
||||||
|
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
||||||
|
};
|
||||||
use crate::issue::{
|
use crate::issue::{
|
||||||
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion,
|
||||||
};
|
};
|
||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
|
use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus};
|
||||||
use crate::permission::ProjectPermissions;
|
use crate::permission::ProjectPermissions;
|
||||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
@ -206,6 +209,27 @@ pub struct Output {
|
|||||||
pub stderr: Vec<u8>,
|
pub stderr: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque handle for a managed local process.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ManagedProcessHandle {
|
||||||
|
/// Stable local process identity as assigned by the adapter.
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observed status of a managed local process.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProcessStatus {
|
||||||
|
/// Process is still running.
|
||||||
|
Running,
|
||||||
|
/// Process has exited.
|
||||||
|
Exited {
|
||||||
|
/// Exit code, when available.
|
||||||
|
code: Option<i32>,
|
||||||
|
},
|
||||||
|
/// Handle is unknown to the adapter.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
|
/// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct RemotePath(pub String);
|
pub struct RemotePath(pub String);
|
||||||
@ -502,6 +526,41 @@ pub enum ProcessError {
|
|||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Errors from local model-server ports.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum ModelServerError {
|
||||||
|
/// The referenced configuration does not exist.
|
||||||
|
#[error("not configured")]
|
||||||
|
NotConfigured,
|
||||||
|
/// Server or configuration is invalid.
|
||||||
|
#[error("invalid: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
/// Filesystem permission denied.
|
||||||
|
#[error("permission denied: {0}")]
|
||||||
|
PermissionDenied(String),
|
||||||
|
/// Required path is not accessible.
|
||||||
|
#[error("path not accessible: {0}")]
|
||||||
|
PathNotAccessible(String),
|
||||||
|
/// The configured port is already in use by another managed server.
|
||||||
|
#[error("port occupied: {0}")]
|
||||||
|
PortOccupied(u16),
|
||||||
|
/// The server is still referenced by another configuration object.
|
||||||
|
#[error("model server in use: {0}")]
|
||||||
|
InUse(String),
|
||||||
|
/// Readiness probe failed unexpectedly.
|
||||||
|
#[error("probe failed: {0}")]
|
||||||
|
Probe(String),
|
||||||
|
/// Process operation failed.
|
||||||
|
#[error("process failed: {0}")]
|
||||||
|
Process(String),
|
||||||
|
/// Store operation failed.
|
||||||
|
#[error("store failed: {0}")]
|
||||||
|
Store(String),
|
||||||
|
/// Readiness timed out.
|
||||||
|
#[error("readiness timed out")]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors from [`FileSystem`].
|
/// Errors from [`FileSystem`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum FsError {
|
pub enum FsError {
|
||||||
@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync {
|
|||||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe readiness of an OpenAI-compatible model server.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ModelServerProbe: Send + Sync {
|
||||||
|
/// Probes `endpoint` and returns the observed status.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on unexpected probe failure. An unreachable server
|
||||||
|
/// should normally return [`ModelServerStatus::Unreachable`].
|
||||||
|
async fn probe(
|
||||||
|
&self,
|
||||||
|
endpoint: &ModelServerEndpoint,
|
||||||
|
) -> Result<ModelServerStatus, ModelServerError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages local long-lived child processes.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ManagedProcess: Send + Sync {
|
||||||
|
/// Spawns a process from an argv-structured spec.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on spawn failure.
|
||||||
|
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError>;
|
||||||
|
|
||||||
|
/// Kills a process.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on kill failure.
|
||||||
|
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>;
|
||||||
|
|
||||||
|
/// Returns the status of a process.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on status failure.
|
||||||
|
async fn status(
|
||||||
|
&self,
|
||||||
|
handle: &ManagedProcessHandle,
|
||||||
|
) -> Result<ProcessStatus, ModelServerError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the argv-structured spawn spec for a local model server.
|
||||||
|
pub trait ModelServerRuntime: Send + Sync {
|
||||||
|
/// Builds a spawn spec from a validated local-server config.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] if the config cannot be launched.
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists local model-server configuration in the global IdeA store.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ModelServerRegistry: Send + Sync {
|
||||||
|
/// Returns one server config by id.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on store failure.
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
id: &LocalModelServerId,
|
||||||
|
) -> Result<Option<LocalModelServerConfig>, ModelServerError>;
|
||||||
|
|
||||||
|
/// Lists all server configs.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on store failure.
|
||||||
|
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError>;
|
||||||
|
|
||||||
|
/// Saves one server config.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on store failure.
|
||||||
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>;
|
||||||
|
|
||||||
|
/// Deletes one server config.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ModelServerError`] on store failure.
|
||||||
|
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Location-neutral filesystem access.
|
/// Location-neutral filesystem access.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FileSystem: Send + Sync {
|
pub trait FileSystem: Send + Sync {
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::DomainError;
|
use crate::error::DomainError;
|
||||||
use crate::ids::ProfileId;
|
use crate::ids::{LocalModelServerId, ProfileId};
|
||||||
use crate::permission::ProjectorKey;
|
use crate::permission::ProjectorKey;
|
||||||
|
|
||||||
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
/// Strategy for injecting an agent's `.md` context into the launched CLI.
|
||||||
@ -295,6 +295,11 @@ pub struct OpenCodeConfig {
|
|||||||
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
|
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub attachment: Option<bool>,
|
pub attachment: Option<bool>,
|
||||||
|
/// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA.
|
||||||
|
///
|
||||||
|
/// `None` garde le comportement existant : profil OpenCode manuel/externe.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub local_model_server_id: Option<LocalModelServerId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCodeConfig {
|
impl OpenCodeConfig {
|
||||||
@ -325,9 +330,17 @@ impl OpenCodeConfig {
|
|||||||
model,
|
model,
|
||||||
reasoning,
|
reasoning,
|
||||||
attachment,
|
attachment,
|
||||||
|
local_model_server_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder : associe cette configuration à un serveur local managé.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self {
|
||||||
|
self.local_model_server_id = Some(id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
|
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reasoning_enabled(&self) -> bool {
|
pub fn reasoning_enabled(&self) -> bool {
|
||||||
@ -1107,7 +1120,7 @@ impl AgentProfile {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mcp_tests {
|
mod mcp_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ids::ProfileId;
|
use crate::ids::{LocalModelServerId, ProfileId};
|
||||||
|
|
||||||
/// A reference profile without any MCP capability (the historical shape).
|
/// A reference profile without any MCP capability (the historical shape).
|
||||||
fn profile_without_mcp() -> AgentProfile {
|
fn profile_without_mcp() -> AgentProfile {
|
||||||
@ -1225,6 +1238,46 @@ mod mcp_tests {
|
|||||||
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
|
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_config_omits_local_model_server_id_when_absent() {
|
||||||
|
let config = OpenCodeConfig::new(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).expect("serialise");
|
||||||
|
assert!(
|
||||||
|
!json.contains("localModelServerId"),
|
||||||
|
"manual external OpenCode profiles must keep the legacy JSON shape: {json}"
|
||||||
|
);
|
||||||
|
let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise");
|
||||||
|
assert_eq!(back.local_model_server_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
||||||
|
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
||||||
|
let config = OpenCodeConfig::new(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_local_model_server_id(server_id);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&config).expect("serialise");
|
||||||
|
assert_eq!(value["localModelServerId"], server_id.to_string());
|
||||||
|
assert!(value.get("local_model_server_id").is_none());
|
||||||
|
let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise");
|
||||||
|
assert_eq!(back.local_model_server_id, Some(server_id));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
|
||||||
let profile = profile_without_mcp()
|
let profile = profile_without_mcp()
|
||||||
|
|||||||
@ -26,6 +26,7 @@ pub mod input;
|
|||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
@ -64,6 +65,9 @@ pub use inspector::{
|
|||||||
};
|
};
|
||||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
pub use mailbox::InMemoryMailbox;
|
pub use mailbox::InMemoryMailbox;
|
||||||
|
pub use model_server::{
|
||||||
|
FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess,
|
||||||
|
};
|
||||||
pub use orchestrator::mcp::{
|
pub use orchestrator::mcp::{
|
||||||
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
|
||||||
ToolPolicyRegistry,
|
ToolPolicyRegistry,
|
||||||
|
|||||||
315
crates/infrastructure/src/model_server/mod.rs
Normal file
315
crates/infrastructure/src/model_server/mod.rs
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
//! Infrastructure adapters for local model servers.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
|
use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint};
|
||||||
|
use domain::ports::{
|
||||||
|
FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
|
||||||
|
ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec,
|
||||||
|
};
|
||||||
|
use domain::{LocalModelServerId, ProjectPath};
|
||||||
|
|
||||||
|
/// HTTP readiness probe for OpenAI-compatible servers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpOpenAiCompatibleProbe {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpOpenAiCompatibleProbe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(Duration::from_secs(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpOpenAiCompatibleProbe {
|
||||||
|
/// Builds the probe with a request timeout.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(timeout: Duration) -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(timeout)
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client with timeout builds");
|
||||||
|
Self { client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelServerProbe for HttpOpenAiCompatibleProbe {
|
||||||
|
async fn probe(
|
||||||
|
&self,
|
||||||
|
endpoint: &ModelServerEndpoint,
|
||||||
|
) -> Result<domain::ModelServerStatus, ModelServerError> {
|
||||||
|
let models = format!("{}/models", endpoint.base_url.trim_end_matches('/'));
|
||||||
|
if is_ready(self.client.get(models).send().await) {
|
||||||
|
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||||
|
}
|
||||||
|
let root = endpoint.base_url.trim_end_matches("/v1").to_owned();
|
||||||
|
if is_ready(self.client.get(root).send().await) {
|
||||||
|
return Ok(domain::ModelServerStatus::ReadyReused);
|
||||||
|
}
|
||||||
|
Ok(domain::ModelServerStatus::Unreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||||
|
result
|
||||||
|
.map(|response| response.status().is_success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds `llama-server` argv without shell interpolation.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct LlamaCppRuntime;
|
||||||
|
|
||||||
|
impl LlamaCppRuntime {
|
||||||
|
/// Creates the runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelServerRuntime for LlamaCppRuntime {
|
||||||
|
fn build_spawn_spec(
|
||||||
|
&self,
|
||||||
|
config: &LocalModelServerConfig,
|
||||||
|
) -> Result<SpawnSpec, ModelServerError> {
|
||||||
|
if config.kind != LocalModelServerKind::LlamaCpp {
|
||||||
|
return Err(ModelServerError::Invalid(
|
||||||
|
"only LlamaCpp local model servers are supported".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let model_path =
|
||||||
|
config.model.path.as_ref().ok_or_else(|| {
|
||||||
|
ModelServerError::PathNotAccessible("model.path missing".to_owned())
|
||||||
|
})?;
|
||||||
|
let command = resolve_binary(
|
||||||
|
config
|
||||||
|
.binary
|
||||||
|
.as_ref()
|
||||||
|
.map(|binary| binary.as_str())
|
||||||
|
.unwrap_or("llama-server"),
|
||||||
|
)?;
|
||||||
|
let mut args = vec![
|
||||||
|
"--model".to_owned(),
|
||||||
|
model_path.as_str().to_owned(),
|
||||||
|
"--port".to_owned(),
|
||||||
|
config.endpoint.port.to_string(),
|
||||||
|
];
|
||||||
|
args.extend(config.args.clone());
|
||||||
|
Ok(SpawnSpec {
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?,
|
||||||
|
env: Vec::new(),
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_binary(raw: &str) -> Result<String, ModelServerError> {
|
||||||
|
if is_path_like(raw) {
|
||||||
|
let path = Path::new(raw);
|
||||||
|
if path.is_file() {
|
||||||
|
return Ok(raw.to_owned());
|
||||||
|
}
|
||||||
|
return Err(ModelServerError::PathNotAccessible(raw.to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(path) = find_in_path(raw) {
|
||||||
|
return Ok(path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
Err(ModelServerError::PathNotAccessible(format!(
|
||||||
|
"{raw} not found in PATH"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_path_like(raw: &str) -> bool {
|
||||||
|
raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_in_path(command: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os("PATH").and_then(|path| {
|
||||||
|
std::env::split_paths(&path)
|
||||||
|
.map(|dir| dir.join(command))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local child-process manager for long-lived model servers.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LocalManagedProcess {
|
||||||
|
children: Mutex<HashMap<String, Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalManagedProcess {
|
||||||
|
/// Creates an empty process manager.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ManagedProcess for LocalManagedProcess {
|
||||||
|
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||||
|
let mut command = Command::new(&spec.command);
|
||||||
|
command.args(&spec.args);
|
||||||
|
if spec.cwd.as_str() != "/" {
|
||||||
|
command.current_dir(spec.cwd.as_str());
|
||||||
|
}
|
||||||
|
for (key, value) in &spec.env {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
let child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?;
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
self.children.lock().unwrap().insert(id.clone(), child);
|
||||||
|
Ok(ManagedProcessHandle { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||||
|
let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
child
|
||||||
|
.start_kill()
|
||||||
|
.map_err(|e| ModelServerError::Process(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(
|
||||||
|
&self,
|
||||||
|
handle: &ManagedProcessHandle,
|
||||||
|
) -> Result<ProcessStatus, ModelServerError> {
|
||||||
|
let mut children = self.children.lock().unwrap();
|
||||||
|
let Some(child) = children.get_mut(&handle.id) else {
|
||||||
|
return Ok(ProcessStatus::Unknown);
|
||||||
|
};
|
||||||
|
match child
|
||||||
|
.try_wait()
|
||||||
|
.map_err(|e| ModelServerError::Process(e.to_string()))?
|
||||||
|
{
|
||||||
|
Some(status) => Ok(ProcessStatus::Exited {
|
||||||
|
code: status.code(),
|
||||||
|
}),
|
||||||
|
None => Ok(ProcessStatus::Running),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File name of the global local-model-server registry.
|
||||||
|
const MODEL_SERVERS_FILE: &str = "model-servers.json";
|
||||||
|
const MODEL_SERVERS_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ModelServersDoc {
|
||||||
|
version: u32,
|
||||||
|
servers: Vec<LocalModelServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelServersDoc {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: MODEL_SERVERS_VERSION,
|
||||||
|
servers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-backed global model-server registry.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsModelServerRegistry {
|
||||||
|
fs: std::sync::Arc<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsModelServerRegistry {
|
||||||
|
/// Builds the registry from a filesystem and global app-data dir.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: std::sync::Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
app_data_dir: app_data_dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> RemotePath {
|
||||||
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||||
|
RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_doc(&self) -> Result<ModelServersDoc, ModelServerError> {
|
||||||
|
match self.fs.read(&self.path()).await {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))),
|
||||||
|
Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()),
|
||||||
|
Err(domain::ports::FsError::PermissionDenied(p)) => {
|
||||||
|
Err(ModelServerError::PermissionDenied(p))
|
||||||
|
}
|
||||||
|
Err(e) => Err(ModelServerError::Store(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> {
|
||||||
|
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||||
|
self.fs.create_dir_all(&dir).await.map_err(|e| match e {
|
||||||
|
domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p),
|
||||||
|
other => ModelServerError::Store(other.to_string()),
|
||||||
|
})?;
|
||||||
|
let bytes = serde_json::to_vec_pretty(doc)
|
||||||
|
.map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?;
|
||||||
|
self.fs
|
||||||
|
.write(&self.path(), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
domain::ports::FsError::PermissionDenied(p) => {
|
||||||
|
ModelServerError::PermissionDenied(p)
|
||||||
|
}
|
||||||
|
other => ModelServerError::Store(other.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelServerRegistry for FsModelServerRegistry {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
id: &LocalModelServerId,
|
||||||
|
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||||
|
Ok(self
|
||||||
|
.read_doc()
|
||||||
|
.await?
|
||||||
|
.servers
|
||||||
|
.into_iter()
|
||||||
|
.find(|server| &server.id == id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||||
|
Ok(self.read_doc().await?.servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||||
|
let mut doc = self.read_doc().await?;
|
||||||
|
if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) {
|
||||||
|
*slot = config;
|
||||||
|
} else {
|
||||||
|
doc.servers.push(config);
|
||||||
|
}
|
||||||
|
self.write_doc(&doc).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||||
|
let mut doc = self.read_doc().await?;
|
||||||
|
doc.servers.retain(|server| server.id != id);
|
||||||
|
self.write_doc(&doc).await
|
||||||
|
}
|
||||||
|
}
|
||||||
111
crates/infrastructure/tests/model_server.rs
Normal file
111
crates/infrastructure/tests/model_server.rs
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//! Infrastructure tests for local model-server adapters.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::model_server::{
|
||||||
|
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||||
|
ModelServerEndpoint, StopPolicy,
|
||||||
|
};
|
||||||
|
use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath};
|
||||||
|
use domain::LocalModelServerId;
|
||||||
|
use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!("idea-model-server-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_dir(&self) -> String {
|
||||||
|
self.0.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child(&self, name: &str) -> RemotePath {
|
||||||
|
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sid(n: u128) -> LocalModelServerId {
|
||||||
|
LocalModelServerId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig {
|
||||||
|
let binary = std::env::current_exe()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
LocalModelServerConfig::new(
|
||||||
|
id,
|
||||||
|
LocalModelServerKind::LlamaCpp,
|
||||||
|
"Local Qwen",
|
||||||
|
ModelServerEndpoint::new(format!("http://localhost:{port}/v1"), port).unwrap(),
|
||||||
|
LocalModelRef::new(
|
||||||
|
"qwen",
|
||||||
|
"Qwen",
|
||||||
|
Some(ModelPath::new("/models/qwen.gguf").unwrap()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some(ExecutablePath::new(binary).unwrap()),
|
||||||
|
vec!["--ctx-size".to_owned(), "8192".to_owned()],
|
||||||
|
true,
|
||||||
|
StopPolicy::StopOnAppExit,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn llamacpp_runtime_builds_structured_argv() {
|
||||||
|
let spec = LlamaCppRuntime::new()
|
||||||
|
.build_spawn_spec(&config(sid(1), 8080))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
spec.command,
|
||||||
|
std::env::current_exe().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spec.args,
|
||||||
|
vec![
|
||||||
|
"--model",
|
||||||
|
"/models/qwen.gguf",
|
||||||
|
"--port",
|
||||||
|
"8080",
|
||||||
|
"--ctx-size",
|
||||||
|
"8192"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(spec.context_plan.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fs_model_server_registry_roundtrips_global_json() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||||
|
let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir());
|
||||||
|
|
||||||
|
let first = config(sid(2), 8081);
|
||||||
|
registry.save(first.clone()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(registry.get(&first.id).await.unwrap(), Some(first.clone()));
|
||||||
|
assert_eq!(registry.list().await.unwrap(), vec![first.clone()]);
|
||||||
|
assert!(
|
||||||
|
fs.exists(&tmp.child("model-servers.json")).await.unwrap(),
|
||||||
|
"registry must live in global app-data dir"
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated = config(sid(2), 8082);
|
||||||
|
registry.save(updated.clone()).await.unwrap();
|
||||||
|
assert_eq!(registry.list().await.unwrap(), vec![updated]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user