merge feature/multi-profiles-codex-clarification-toast-notices dans develop

Résout le conflit d'imports/helpers de tests dans
crates/application/tests/model_server.rs par union des deux côtés
(imports application/domain + helpers aid/nid/sess).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 13:06:35 +02:00
63 changed files with 5674 additions and 993 deletions

View File

@ -11,33 +11,101 @@ use domain::model_server::{
ModelSource,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, ProjectStore, RemotePath,
};
use domain::{LocalModelServerId, ProjectId, StopPolicy};
use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::Instant;
use crate::error::AppError;
use crate::terminal::LiveAgentRegistry;
/// Artifact cache state exposed by model-server list use cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelArtifactView {
/// The configured source is not managed by IdeA's downloader.
NotManaged,
/// The configured source is managed but not present in cache.
Missing,
/// A download/prepare operation is currently running for this server.
Downloading,
/// The configured source is present in cache.
Downloaded {
/// Local artifact path used by llama.cpp.
path: String,
/// Total on-disk size when known.
size_bytes: Option<u64>,
},
}
impl From<ModelArtifactState> for ModelArtifactView {
fn from(state: ModelArtifactState) -> Self {
match state {
ModelArtifactState::NotManaged => Self::NotManaged,
ModelArtifactState::Missing => Self::Missing,
ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded {
path: path.as_str().to_owned(),
size_bytes,
},
}
}
}
/// A configured local model server plus derived artifact state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServerListItem {
/// Persisted local model-server config.
pub config: LocalModelServerConfig,
/// Derived artifact cache state.
pub artifact: ModelArtifactView,
}
/// Output of [`ListModelServers::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelServersOutput {
/// Persisted local model-server configs.
pub servers: Vec<LocalModelServerConfig>,
/// Persisted local model-server configs enriched with artifact state.
pub servers: Vec<ModelServerListItem>,
}
/// Lists local model-server configurations.
pub struct ListModelServers {
registry: Arc<dyn ModelServerRegistry>,
downloader: Option<Arc<dyn ModelArtifactDownloader>>,
downloads: Option<Arc<dyn ModelArtifactDownloadTracker>>,
}
impl ListModelServers {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
Self {
registry,
downloader: None,
downloads: None,
}
}
/// Enables artifact state enrichment for Hugging Face-backed servers.
#[must_use]
pub fn with_model_artifact_downloader(
mut self,
downloader: Arc<dyn ModelArtifactDownloader>,
) -> Self {
self.downloader = Some(downloader);
self
}
/// Enables in-flight download state enrichment.
#[must_use]
pub fn with_download_tracker(
mut self,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
) -> Self {
self.downloads = Some(downloads);
self
}
/// Lists configs.
@ -45,10 +113,44 @@ impl ListModelServers {
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
Ok(ListModelServersOutput {
servers: self.registry.list().await?,
})
let configs = self.registry.list().await?;
let mut servers = Vec::with_capacity(configs.len());
for config in configs {
let artifact = self.artifact_view(&config).await?;
servers.push(ModelServerListItem { config, artifact });
}
Ok(ListModelServersOutput { servers })
}
async fn artifact_view(
&self,
config: &LocalModelServerConfig,
) -> Result<ModelArtifactView, AppError> {
if self
.downloads
.as_ref()
.is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id))
{
return Ok(ModelArtifactView::Downloading);
}
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
return Ok(ModelArtifactView::NotManaged);
};
let Some(downloader) = self.downloader.as_ref() else {
return Ok(ModelArtifactView::Missing);
};
downloader
.hf_model_state(repo)
.await
.map(ModelArtifactView::from)
.map_err(AppError::from)
}
}
/// Read-only in-flight download state shared by list/delete use cases.
pub trait ModelArtifactDownloadTracker: Send + Sync {
/// Whether the model artifact for `server_id` is currently being resolved/downloaded.
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool;
}
/// Input for [`SaveModelServer::execute`].
@ -132,6 +234,154 @@ impl DeleteModelServer {
}
}
/// Input for [`DeleteModelArtifact::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteModelArtifactInput {
/// Config id whose managed artifact cache should be deleted.
pub server_id: LocalModelServerId,
}
/// Deletes a downloaded model artifact without deleting the server config.
pub struct DeleteModelArtifact {
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
downloader: Arc<dyn ModelArtifactDownloader>,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
contexts: Arc<dyn AgentContextStore>,
live: Arc<dyn LiveAgentRegistry>,
}
impl DeleteModelArtifact {
/// Builds the use case.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
downloader: Arc<dyn ModelArtifactDownloader>,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
contexts: Arc<dyn AgentContextStore>,
live: Arc<dyn LiveAgentRegistry>,
) -> Self {
Self {
registry,
probe,
downloader,
downloads,
profiles,
projects,
contexts,
live,
}
}
/// Deletes a managed Hugging Face artifact after safety checks.
///
/// # Errors
/// [`AppError::ModelServer`] when the server is missing, the source is not
/// deletable, a download is active, or a live agent uses the server.
pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> {
let config = self
.registry
.get(&input.server_id)
.await?
.ok_or(ModelServerError::NotConfigured)?;
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
return Err(ModelServerError::Invalid(
"only managed Hugging Face model artifacts can be deleted".to_owned(),
)
.into());
};
if self
.downloads
.is_model_artifact_download_in_progress(input.server_id)
{
return Err(ModelServerError::InUse(format!(
"model artifact download in progress for {}",
input.server_id
))
.into());
}
self.ensure_server_not_reachable(&config).await?;
self.ensure_not_used_by_live_agent(input.server_id).await?;
self.downloader.delete_hf_model(repo).await?;
Ok(())
}
async fn ensure_server_not_reachable(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
match self.probe.probe(&config.endpoint).await? {
ModelServerStatus::Unreachable => Ok(()),
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
Err(ModelServerError::InUse(format!(
"model server {} is currently reachable",
config.id
))
.into())
}
}
}
async fn ensure_not_used_by_live_agent(
&self,
server_id: LocalModelServerId,
) -> Result<(), AppError> {
let profiles = self.profiles.list().await?;
let profile_server: HashMap<_, _> = profiles
.iter()
.filter_map(|profile| {
profile
.opencode
.as_ref()
.and_then(|opencode| opencode.local_model_server_id)
.map(|id| (profile.id, id))
})
.collect();
let mut agents_by_project = HashMap::new();
for snapshot in self.live.live_agent_snapshots() {
let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) {
agents
} else {
let project = self.projects.load_project(snapshot.project_id).await?;
let manifest = self.contexts.load_manifest(&project).await?;
agents_by_project.insert(
snapshot.project_id,
manifest
.entries
.iter()
.map(|entry| {
entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))
})
.collect::<Result<Vec<_>, _>>()?,
);
agents_by_project
.get(&snapshot.project_id)
.expect("project agents inserted")
};
let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else {
continue;
};
if profile_server.get(&agent.profile_id) == Some(&server_id) {
return Err(ModelServerError::InUse(format!(
"model server {server_id} is used by live agent {}",
agent.id
))
.into());
}
}
Ok(())
}
}
/// Input for [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerInput {
@ -738,6 +988,15 @@ impl EnsureLocalModelServer {
}
}
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
self.download_cancels
.lock()
.unwrap()
.contains_key(&server_id)
}
}
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
ModelServerReady {
base_url: config.endpoint.base_url.clone(),