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:
@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
|
||||
use crate::project::project_context_path;
|
||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||
use crate::workstate::GetLiveStateLean;
|
||||
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
|
||||
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
|
||||
/// parse ⇒ section omise, jamais d'échec de lancement.
|
||||
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
|
||||
/// Ensure local model servers referenced by OpenCode profiles before writing
|
||||
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
|
||||
/// when `OpenCodeConfig.localModelServerId` is set.
|
||||
local_model_server: Option<Arc<EnsureLocalModelServer>>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -1174,9 +1179,17 @@ impl LaunchAgent {
|
||||
provider_sessions: None,
|
||||
projectors: None,
|
||||
live_state_lean: None,
|
||||
local_model_server: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||
#[must_use]
|
||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||
self.local_model_server = Some(ensure);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
||||
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
||||
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
||||
@ -1550,7 +1563,7 @@ impl LaunchAgent {
|
||||
.contexts
|
||||
.read_context(&input.project, &agent.id)
|
||||
.await?;
|
||||
let profile = self
|
||||
let mut profile = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
@ -1649,6 +1662,9 @@ impl LaunchAgent {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
|
||||
.await?;
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||
@ -2336,6 +2352,60 @@ impl LaunchAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_local_model_server_for_opencode(
|
||||
&self,
|
||||
agent: &Agent,
|
||||
profile: &mut AgentProfile,
|
||||
) -> Result<(), AppError> {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(opencode) = profile.opencode.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(server_id) = opencode.local_model_server_id else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(ensure) = self.local_model_server.as_ref() else {
|
||||
let err = AppError::ModelServer {
|
||||
code: "not_wired".to_owned(),
|
||||
message:
|
||||
"OpenCode profile references localModelServerId but no model server service is wired"
|
||||
.to_owned(),
|
||||
};
|
||||
self.publish_agent_launch_failed(agent.id, &err);
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
match ensure
|
||||
.execute(EnsureLocalModelServerInput { server_id })
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
opencode.base_url = output.ready.base_url;
|
||||
opencode.model = output.ready.model;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.publish_agent_launch_failed(agent.id, &err);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
|
||||
let (cause, code) = match err {
|
||||
AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
|
||||
_ => ("launch", err.code()),
|
||||
};
|
||||
self.events.publish(DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause: cause.to_owned(),
|
||||
code: code.to_owned(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||||
|
||||
@ -41,8 +41,10 @@ pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
|
||||
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
@ -13,12 +13,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentRuntime, ProfileStore};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
|
||||
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::selectable_reference_profiles;
|
||||
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneOpenCodeProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CloneOpenCodeProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneOpenCodeProfileFromSeedInput {
|
||||
/// Optional display name for the cloned profile. When absent, a copy label is
|
||||
/// derived from the seed name.
|
||||
pub name: Option<String>,
|
||||
/// Optional OpenCode config override. When absent, the seed config is copied.
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
}
|
||||
|
||||
/// Output of [`CloneOpenCodeProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneOpenCodeProfileFromSeedOutput {
|
||||
/// The newly persisted profile.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Creates a new OpenCode profile instance from the canonical seed/template.
|
||||
///
|
||||
/// The persisted canonical `opencode-llamacpp` profile is preferred when present,
|
||||
/// so local edits are preserved as the clone template. If it is absent, the
|
||||
/// in-memory reference catalogue seed is used. The new profile always receives a
|
||||
/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles
|
||||
/// with `StructuredAdapter::OpenCode` are therefore valid.
|
||||
pub struct CloneOpenCodeProfileFromSeed {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CloneOpenCodeProfileFromSeed {
|
||||
/// Builds the use case from the profile store and id generator ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { store, ids }
|
||||
}
|
||||
|
||||
/// Clones the canonical OpenCode seed into a new persisted profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the
|
||||
/// requested name is blank, or [`AppError::Internal`] if the seed is malformed.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloneOpenCodeProfileFromSeedInput,
|
||||
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
|
||||
let existing = self.store.list().await?;
|
||||
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||
let seed = existing
|
||||
.iter()
|
||||
.find(|profile| profile.id == seed_id)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == seed_id)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into())
|
||||
})?;
|
||||
|
||||
if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() {
|
||||
return Err(AppError::Internal(
|
||||
"canonical OpenCode seed is not an OpenCode profile".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut profile = seed;
|
||||
profile.id = fresh_profile_id(&*self.ids, &existing)?;
|
||||
profile.name = match input.name {
|
||||
Some(name) => {
|
||||
if name.trim().is_empty() {
|
||||
return Err(AppError::Invalid("profile.name must not be empty".into()));
|
||||
}
|
||||
name
|
||||
}
|
||||
None => format!("{} copy", profile.name),
|
||||
};
|
||||
if let Some(config) = input.opencode {
|
||||
profile.opencode = Some(config);
|
||||
}
|
||||
|
||||
self.store.save(&profile).await?;
|
||||
Ok(CloneOpenCodeProfileFromSeedOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_profile_id(
|
||||
ids: &dyn IdGenerator,
|
||||
existing: &[AgentProfile],
|
||||
) -> Result<ProfileId, AppError> {
|
||||
for _ in 0..16 {
|
||||
let id = ProfileId::from_uuid(ids.new_uuid());
|
||||
if existing.iter().all(|profile| profile.id != id) {
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
Err(AppError::Internal(
|
||||
"could not allocate a unique profile id".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Persists (creates or replaces) a single profile.
|
||||
pub struct SaveProfile {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||
RemoteError, RuntimeError, StoreError,
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||
ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
@ -38,6 +38,15 @@ pub enum AppError {
|
||||
#[error("process error: {0}")]
|
||||
Process(String),
|
||||
|
||||
/// A local model server could not be prepared for launch.
|
||||
#[error("model server error ({code}): {message}")]
|
||||
ModelServer {
|
||||
/// Stable model-server error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// A git operation failed.
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
@ -101,6 +110,7 @@ impl AppError {
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
Self::ModelServer { .. } => "MODEL_SERVER",
|
||||
Self::Git(_) => "GIT",
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||
@ -183,6 +193,30 @@ impl From<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 {
|
||||
fn from(e: RuntimeError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
|
||||
@ -22,6 +22,7 @@ pub mod health;
|
||||
pub mod issues;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
@ -39,19 +40,20 @@ pub use agent::{
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
|
||||
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
|
||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles,
|
||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
||||
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||
@ -102,6 +104,12 @@ pub use memory::{
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use model_server::{
|
||||
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||
SaveModelServerInput, SaveModelServerOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user