fix: livrable tickets #70 #100 #102 — UX surface scoping

- #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:
2026-07-26 19:42:06 +02:00
parent 4321d048ac
commit 02603441c1
31 changed files with 1589 additions and 152 deletions

View File

@ -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 },

View File

@ -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,

View File

@ -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();

View File

@ -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,

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, 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(),

View File

@ -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)
}
}

View File

@ -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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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());
}

View File

@ -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(),
)
}

View File

@ -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,

View File

@ -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.

View File

@ -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;

View File

@ -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
);
}