- #70: implémentation suppression modèles locaux téléchargés - #100: correction scroll OpenCode - #102: correction fit TUI après switch/layout - memory note scoping UX
This commit is contained in:
@ -1339,6 +1339,7 @@ pub async fn save_model_server(
|
||||
.map_err(ErrorDto::from)?
|
||||
.servers
|
||||
.into_iter()
|
||||
.map(|item| item.config)
|
||||
.find(|config| config.id == server_id);
|
||||
let input = save_model_server_input(request, existing.as_ref())?;
|
||||
state
|
||||
@ -1388,6 +1389,26 @@ pub async fn delete_model_server(
|
||||
.map_err(model_server_command_error)
|
||||
}
|
||||
|
||||
/// `delete_model_artifact` — delete a managed downloaded model artifact while
|
||||
/// keeping the local model-server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use`
|
||||
/// when a download or live agent blocks deletion, and model-server errors for
|
||||
/// cache I/O failures.
|
||||
#[tauri::command]
|
||||
pub async fn delete_model_artifact(
|
||||
server_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let server_id = parse_model_server_id(&server_id)?;
|
||||
state
|
||||
.delete_model_artifact
|
||||
.execute(application::DeleteModelArtifactInput { 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 },
|
||||
|
||||
@ -265,6 +265,7 @@ pub fn run() {
|
||||
commands::save_model_server,
|
||||
commands::preview_model_server_command,
|
||||
commands::delete_model_server,
|
||||
commands::delete_model_artifact,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
commands::delete_embedder_profile,
|
||||
|
||||
@ -132,6 +132,7 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
auto_start: true,
|
||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||
warmup_deadline_secs: Some(900),
|
||||
artifact: Default::default(),
|
||||
};
|
||||
|
||||
let config = dto.into_domain(Some(&existing)).unwrap();
|
||||
|
||||
@ -127,10 +127,12 @@ pub use memory::{
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use model_server::{
|
||||
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||
SaveModelServerInput, SaveModelServerOutput,
|
||||
model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer,
|
||||
DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput,
|
||||
ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem,
|
||||
ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -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, 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 {
|
||||
@ -655,6 +905,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(),
|
||||
|
||||
@ -64,6 +64,11 @@ pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
|
||||
/// would be wrongly marked as still running).
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool;
|
||||
|
||||
/// Snapshots every live agent session currently known by this registry.
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
@ -89,6 +94,26 @@ impl LiveAgentRegistry for TerminalSessions {
|
||||
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
@ -426,6 +451,23 @@ impl LiveAgentRegistry for StructuredSessions {
|
||||
.map(|m| m.values().any(|e| e.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredSessions {
|
||||
@ -819,42 +861,8 @@ impl LiveSessions {
|
||||
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
|
||||
#[must_use]
|
||||
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
let mut all: Vec<LiveSessionSnapshot> = self
|
||||
.pty
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
all.extend(
|
||||
self.structured
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let mut all = self.pty.live_agent_snapshots();
|
||||
all.extend(self.structured.live_agent_snapshots());
|
||||
all
|
||||
}
|
||||
}
|
||||
@ -868,4 +876,8 @@ impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
LiveSessions::live_agent_snapshots(self)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,28 +7,52 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
ModelServerReadinessPolicy,
|
||||
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
|
||||
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
|
||||
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy,
|
||||
};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
|
||||
ModelServerStatus, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
AgentContextStore, DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess,
|
||||
ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, ModelArtifactState, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore,
|
||||
ProjectStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||
use domain::project::Project;
|
||||
use domain::{
|
||||
AgentId, AgentManifest, LocalModelServerId, ManifestEntry, NodeId, ProfileId, ProjectId,
|
||||
ProjectPath, RemoteRef, SessionId,
|
||||
};
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sess(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(
|
||||
id: LocalModelServerId,
|
||||
port: u16,
|
||||
@ -294,18 +318,27 @@ enum FakeDownloadOutcome {
|
||||
|
||||
struct FakeModelArtifactDownloader {
|
||||
outcome: Mutex<FakeDownloadOutcome>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeModelArtifactDownloader {
|
||||
fn new(outcome: FakeDownloadOutcome) -> Self {
|
||||
Self {
|
||||
outcome: Mutex::new(outcome),
|
||||
deleted: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
_repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -347,6 +380,123 @@ impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
self.deleted.lock().unwrap().push(repo.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeDownloadTracker {
|
||||
in_progress: Mutex<Vec<LocalModelServerId>>,
|
||||
}
|
||||
|
||||
impl ModelArtifactDownloadTracker for FakeDownloadTracker {
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
||||
self.in_progress.lock().unwrap().contains(&server_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeLive {
|
||||
snapshots: Vec<LiveSessionSnapshot>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for FakeLive {
|
||||
fn is_agent_live(&self, _project_id: ProjectId, _agent_id: &AgentId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<application::LiveSessionSnapshot> {
|
||||
self.snapshots.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProjects {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
impl Default for FakeProjects {
|
||||
fn default() -> Self {
|
||||
Self { project_id: pid(1) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
if id != self.project_id {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Project::new(
|
||||
id,
|
||||
"Test",
|
||||
ProjectPath::new("/tmp/unused").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.map_err(|err| StoreError::Invalid(err.to_string()))
|
||||
}
|
||||
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace {
|
||||
windows: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeContexts {
|
||||
manifest: AgentManifest,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
Ok(MarkdownDoc::new(""))
|
||||
}
|
||||
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
_md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -427,6 +577,46 @@ fn ensure_with_downloader(
|
||||
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
) -> DeleteModelArtifact {
|
||||
delete_artifact_usecase_with_live(
|
||||
registry,
|
||||
downloader,
|
||||
tracker,
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase_with_live(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
probe: Arc<FakeProbe>,
|
||||
projects: Arc<FakeProjects>,
|
||||
contexts: Arc<FakeContexts>,
|
||||
live: Arc<FakeLive>,
|
||||
) -> DeleteModelArtifact {
|
||||
DeleteModelArtifact::new(
|
||||
registry as Arc<dyn ModelServerRegistry>,
|
||||
probe as Arc<dyn ModelServerProbe>,
|
||||
downloader as Arc<dyn ModelArtifactDownloader>,
|
||||
tracker as Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles as Arc<dyn ProfileStore>,
|
||||
projects as Arc<dyn ProjectStore>,
|
||||
contexts as Arc<dyn AgentContextStore>,
|
||||
live as Arc<dyn LiveAgentRegistry>,
|
||||
)
|
||||
}
|
||||
|
||||
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
|
||||
ModelArtifactProgress {
|
||||
downloaded_bytes: downloaded,
|
||||
@ -1325,3 +1515,218 @@ async fn delete_model_server_removes_unused_config() {
|
||||
|
||||
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_local_path_source() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(25), 8105, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/model.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(25) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"),
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
assert!(registry.get(&sid(25)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_download_in_progress() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let tracker = Arc::new(FakeDownloadTracker::default());
|
||||
tracker.in_progress.lock().unwrap().push(sid(26));
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
tracker,
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(26) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_live_agent_using_server_profile() {
|
||||
let server_id = sid(27);
|
||||
let project_id = pid(27);
|
||||
let agent_id = aid(27);
|
||||
let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270));
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(
|
||||
profile_id.as_uuid().as_u128(),
|
||||
server_id,
|
||||
)])));
|
||||
let contexts = Arc::new(FakeContexts {
|
||||
manifest: AgentManifest::new(
|
||||
1,
|
||||
vec![ManifestEntry::new(
|
||||
agent_id,
|
||||
"Local Agent",
|
||||
"agents/local.md",
|
||||
profile_id,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap(),
|
||||
});
|
||||
let live = Arc::new(FakeLive {
|
||||
snapshots: vec![LiveSessionSnapshot {
|
||||
project_id,
|
||||
agent_id,
|
||||
node_id: nid(27),
|
||||
session_id: sess(27),
|
||||
kind: LiveSessionKind::Pty,
|
||||
}],
|
||||
});
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects { project_id }),
|
||||
contexts,
|
||||
live,
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_reachable_server_endpoint() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(28) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(27) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
downloader.deleted.lock().unwrap().as_slice(),
|
||||
["Qwen/Qwen3-Coder:Q4_K_M"]
|
||||
);
|
||||
assert!(registry.get(&sid(27)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
@ -1343,7 +1343,10 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
// Local model servers (B35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use application::{
|
||||
ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
@ -1492,6 +1495,9 @@ pub struct ModelServerConfigDto {
|
||||
/// Optional readiness warmup deadline override in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warmup_deadline_secs: Option<u64>,
|
||||
/// Derived local artifact cache state.
|
||||
#[serde(default)]
|
||||
pub artifact: ModelArtifactDto,
|
||||
}
|
||||
|
||||
impl ModelServerConfigDto {
|
||||
@ -1516,6 +1522,7 @@ impl ModelServerConfigDto {
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
warmup_deadline_secs: config.warmup_deadline_secs,
|
||||
artifact: ModelArtifactDto::NotManaged,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1563,6 +1570,53 @@ impl ModelServerConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local model artifact cache state on the IPC wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum ModelArtifactDto {
|
||||
/// 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.
|
||||
path: String,
|
||||
/// Total on-disk size when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ModelArtifactDto {
|
||||
fn default() -> Self {
|
||||
Self::NotManaged
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelArtifactView> for ModelArtifactDto {
|
||||
fn from(view: ModelArtifactView) -> Self {
|
||||
match view {
|
||||
ModelArtifactView::NotManaged => Self::NotManaged,
|
||||
ModelArtifactView::Missing => Self::Missing,
|
||||
ModelArtifactView::Downloading => Self::Downloading,
|
||||
ModelArtifactView::Downloaded { path, size_bytes } => {
|
||||
Self::Downloaded { path, size_bytes }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerListItem> for ModelServerConfigDto {
|
||||
fn from(item: ModelServerListItem) -> Self {
|
||||
let mut dto = Self::from_domain(item.config);
|
||||
dto.artifact = item.artifact.into();
|
||||
dto
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `preview_model_server_command`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1585,7 +1639,7 @@ impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||
Self(
|
||||
out.servers
|
||||
.into_iter()
|
||||
.map(ModelServerConfigDto::from_domain)
|
||||
.map(ModelServerConfigDto::from)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,15 +19,15 @@ use application::{
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
@ -59,8 +59,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
@ -965,6 +965,8 @@ pub struct BackendCore {
|
||||
pub save_model_server: Arc<SaveModelServer>,
|
||||
/// Deletes local model server configurations when unused.
|
||||
pub delete_model_server: Arc<DeleteModelServer>,
|
||||
/// Deletes managed local model artifacts without deleting server configs.
|
||||
pub delete_model_artifact: Arc<DeleteModelArtifact>,
|
||||
/// The local PTY adapter, kept port-typed so driving adapters can subscribe
|
||||
/// output and route it through their own transport bridge.
|
||||
pub pty_port: Arc<dyn PtyPort>,
|
||||
@ -1513,25 +1515,31 @@ impl BackendCore {
|
||||
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
|
||||
app_data_dir.join("hf-model-artifacts"),
|
||||
));
|
||||
let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>;
|
||||
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::clone(&model_server_probe_port),
|
||||
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),
|
||||
)
|
||||
.with_model_artifact_downloader(
|
||||
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
|
||||
),
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader)
|
||||
as Arc<dyn domain::ports::ModelArtifactDownloader>),
|
||||
);
|
||||
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 model_artifact_downloader_port =
|
||||
Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
|
||||
let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
|
||||
as Arc<dyn application::ModelArtifactDownloadTracker>;
|
||||
let list_model_servers = Arc::new(
|
||||
ListModelServers::new(Arc::clone(&model_server_registry_port))
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
|
||||
.with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
|
||||
);
|
||||
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
@ -2387,6 +2395,16 @@ impl BackendCore {
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&structured_sessions),
|
||||
));
|
||||
let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
|
||||
Arc::clone(&model_server_registry_port),
|
||||
Arc::clone(&model_server_probe_port),
|
||||
Arc::clone(&model_artifact_downloader_port),
|
||||
Arc::clone(&model_artifact_download_tracker),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
|
||||
));
|
||||
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
|
||||
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
|
||||
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
|
||||
@ -2699,6 +2717,7 @@ impl BackendCore {
|
||||
list_model_servers,
|
||||
save_model_server,
|
||||
delete_model_server,
|
||||
delete_model_artifact,
|
||||
pty_port,
|
||||
terminal_sessions,
|
||||
event_bus,
|
||||
|
||||
@ -1311,6 +1311,22 @@ pub struct ModelArtifactResolution {
|
||||
pub cache_hit: bool,
|
||||
}
|
||||
|
||||
/// Cache state for a model artifact managed by IdeA.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelArtifactState {
|
||||
/// Artifact source is not managed by the downloader.
|
||||
NotManaged,
|
||||
/// Managed artifact is not present in cache.
|
||||
Missing,
|
||||
/// Managed artifact is present in cache.
|
||||
Downloaded {
|
||||
/// Local path used to launch the model.
|
||||
path: ModelPath,
|
||||
/// Total on-disk bytes when known.
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cooperative cancellation token for model artifact resolution.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelArtifactCancel {
|
||||
@ -1339,6 +1355,15 @@ impl ModelArtifactCancel {
|
||||
/// Resolves or downloads a model artifact before starting a model server.
|
||||
#[async_trait]
|
||||
pub trait ModelArtifactDownloader: Send + Sync {
|
||||
/// Returns the current cache state for a Hugging Face model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when the cache cannot be inspected.
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError>;
|
||||
|
||||
/// Resolves a Hugging Face model to a local artifact path.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1349,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync {
|
||||
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError>;
|
||||
|
||||
/// Deletes the cached artifact for a Hugging Face model reference.
|
||||
///
|
||||
/// Deleting a missing artifact is a successful no-op.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when deletion fails.
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -10,6 +10,7 @@ use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
@ -17,9 +18,9 @@ use domain::model_server::{
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||
SpawnSpec,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
||||
|
||||
@ -76,6 +77,7 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
pub struct HfModelArtifactDownloader {
|
||||
cache_dir: PathBuf,
|
||||
client: reqwest::Client,
|
||||
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl HfModelArtifactDownloader {
|
||||
@ -85,6 +87,7 @@ impl HfModelArtifactDownloader {
|
||||
Self {
|
||||
cache_dir: cache_dir.into(),
|
||||
client: reqwest::Client::new(),
|
||||
repo_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +152,62 @@ impl HfModelArtifactDownloader {
|
||||
std::fs::write(manifest_path, json)
|
||||
}
|
||||
|
||||
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
|
||||
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(repo.as_str().to_owned())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(file_size(&merged_path)?),
|
||||
path: model_path_from_pathbuf(merged_path)?,
|
||||
});
|
||||
}
|
||||
if let Some(paths) = self.cached_shard_set(repo) {
|
||||
if let Some(first) = paths.first() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(paths_size(&paths)?),
|
||||
path: model_path_from_pathbuf(first.clone())?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
remove_file_if_exists(&merged_path)?;
|
||||
}
|
||||
|
||||
let manifest_path = self.manifest_path_for(repo);
|
||||
if let Ok(raw) = std::fs::read(&manifest_path) {
|
||||
let manifest: ShardManifest =
|
||||
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
let dir = self.cache_dir_for(repo);
|
||||
for filename in manifest.files {
|
||||
remove_file_if_exists(&dir.join(filename))?;
|
||||
}
|
||||
remove_file_if_exists(&manifest_path)?;
|
||||
}
|
||||
|
||||
let repo_dir = self.cache_dir_for(repo);
|
||||
if repo_dir.is_dir()
|
||||
&& std::fs::read_dir(&repo_dir)
|
||||
.map(is_empty_dir)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_remote_filenames(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -189,12 +248,23 @@ struct ShardManifest {
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.cached_state(repo)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
cache_hit: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.delete_cached(repo)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError>
|
||||
.map_err(|e| ModelServerError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
|
||||
std::fs::metadata(path)
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))
|
||||
}
|
||||
|
||||
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| file_size(path))
|
||||
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(ModelServerError::Store(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
|
||||
entries.into_iter().next().is_none()
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::model_server::{
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
|
||||
ModelServerRuntime, RemotePath,
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactState,
|
||||
ModelServerRegistry, ModelServerRuntime, RemotePath,
|
||||
};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{
|
||||
@ -227,3 +227,41 @@ async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_wit
|
||||
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_reports_downloaded_cache_state() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
let state = downloader.hf_model_state(&repo).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state,
|
||||
ModelArtifactState::Downloaded {
|
||||
path: ModelPath::new(path.to_string_lossy()).unwrap(),
|
||||
size_bytes: Some(4),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_deletes_merged_cache_without_config_side_effects() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
downloader.delete_hf_model(&repo).await.unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
downloader.hf_model_state(&repo).await.unwrap(),
|
||||
ModelArtifactState::Missing
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user