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

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