diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 5d8a66b..e4d95b9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -72,3 +72,4 @@ - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix - [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping - [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +- [tickets-70-100-102-ux-surface-scoping](tickets-70-100-102-ux-surface-scoping.md) — memory note tickets-70-100-102-ux-surface-scoping diff --git a/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md new file mode 100644 index 0000000..ed0310f --- /dev/null +++ b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md @@ -0,0 +1,51 @@ +--- +name: tickets-70-100-102-ux-surface-scoping +description: memory note tickets-70-100-102-ux-surface-scoping +metadata: + type: project +--- +--- +title: Cadrage UX tickets #70 #100 #102 — modèles locaux et bugs terminal +type: design +description: Surface utilisateur attendue pour supprimer les modèles llama.cpp téléchargés, et règle UX pour les bugs de scroll/fit OpenCode qui doivent être corrigés sans nouvelle UI. +--- + +# Cadrage UX tickets #70 #100 #102 + +## #70 — Gestion des modèles locaux téléchargés + +Ajouter une affordance humaine de suppression des artefacts de modèles téléchargés par les serveurs locaux llama.cpp, dans la surface existante `Local model servers` / configuration OpenCode locale. + +Principes visibles : +- La suppression d'un serveur déclaré et la suppression du fichier modèle téléchargé sont deux actions distinctes. +- Une action destructive sur le fichier modèle doit être explicite, confirmée, et impossible pendant un usage actif/téléchargement du même artefact. +- Le libellé doit parler de `modèle téléchargé`, pas de cache interne ou chemin technique en premier niveau. +- Les modèles issus d'un `localPath` utilisateur ne doivent jamais être proposés à la suppression comme s'ils appartenaient à IdeA. + +États attendus par serveur : +- Aucun modèle téléchargé connu : aucun bouton de suppression de modèle, ou bouton désactivé avec tooltip `Aucun modèle téléchargé par IdeA`. +- Modèle téléchargé disponible : bouton secondaire/destructif `Supprimer le modèle téléchargé`. +- Téléchargement/préparation en cours : action désactivée, texte `Téléchargement en cours`. +- Serveur/agent utilisant ce modèle : action désactivée, texte `Modèle utilisé par un agent en cours`. +- Suppression en cours : ligne locale occupée, action désactivée, message `Suppression du modèle...`. +- Succès : toast/status non bloquant `Modèle téléchargé supprimé` ; la configuration serveur reste présente. +- Échec : alerte inline `Impossible de supprimer le modèle téléchargé : `. + +Confirmation : +- Titre : `Supprimer le modèle téléchargé ?` +- Corps : `Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.` +- Si la taille est connue : ajouter `Espace libéré : .` +- Action principale destructive : `Supprimer le modèle` +- Action secondaire : `Annuler` + +## #100 — Scroll OpenCode + +Pas de décision UX spécifique. Le comportement attendu est celui d'une cellule terminal native : l'utilisateur peut remonter dans le scrollback OpenCode jusqu'à la limite de rétention disponible, avec molette, trackpad, scrollbar et clavier, sans blocage prématuré propre à OpenCode. + +Ne pas ajouter de bouton, message ou mode spécial OpenCode. QA doit valider le comportement visible dans une cellule OpenCode longue. + +## #102 — Fit TUI après switch/layout/ajout cellule + +Pas de nouvelle surface UX spécifique. Le terminal doit s'afficher correctement automatiquement après switch de projet, switch de layout, ajout/suppression/split/resize de cellules et rattachement d'une session existante. + +Ne pas afficher de message demandant à l'utilisateur de redimensionner. Éviter tout flash durable vide/noir ; un voile technique transitoire n'est acceptable que s'il reste très bref et non bloquant. \ No newline at end of file diff --git a/.ideai/tickets/100/issue.md b/.ideai/tickets/100/issue.md index 65569e5..0da3257 100644 --- a/.ideai/tickets/100/issue.md +++ b/.ideai/tickets/100/issue.md @@ -2,15 +2,15 @@ id: "4709958c-5082-44fd-a1fd-d6bad85f9361" number: 100 title: "[Bug] Problème sur le scroll des agents OpenCode" -status: "open" +status: "closed" priority: "high" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784992615586 -updatedAt: 1784993984569 -version: 3 +updatedAt: 1785083912470 +version: 4 --- -Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. \ No newline at end of file +Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. diff --git a/.ideai/tickets/102/issue.md b/.ideai/tickets/102/issue.md index 9f6c347..a13e871 100644 --- a/.ideai/tickets/102/issue.md +++ b/.ideai/tickets/102/issue.md @@ -2,15 +2,15 @@ id: "e91fd358-da94-4382-aa70-6a3fe5a63840" number: 102 title: "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent" -status: "open" +status: "closed" priority: "medium" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784993319700 -updatedAt: 1784993980505 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement \ No newline at end of file +J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement diff --git a/.ideai/tickets/70/issue.md b/.ideai/tickets/70/issue.md index 945c969..0587f78 100644 --- a/.ideai/tickets/70/issue.md +++ b/.ideai/tickets/70/issue.md @@ -2,15 +2,15 @@ id: "e73ed25f-8e22-48a5-84ea-521212ea80b5" number: 70 title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp" -status: "open" +status: "closed" priority: "low" sprint: null links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784194016529 -updatedAt: 1784194049447 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer \ No newline at end of file +Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 053f7df..5c590ed 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 104 + "nextNumber": 107 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 462a4ec..be21241 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -731,13 +731,13 @@ "issueRef": "#70", "path": "70", "title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784194049447 + "updatedAt": 1785083912470 }, { "issueRef": "#71", @@ -1035,11 +1035,11 @@ "issueRef": "#100", "path": "100", "title": "[Bug] Problème sur le scroll des agents OpenCode", - "status": "open", + "status": "closed", "priority": "high", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [], - "updatedAt": 1784993984569 + "updatedAt": 1785083912470 }, { "issueRef": "#101", @@ -1057,13 +1057,13 @@ "issueRef": "#102", "path": "102", "title": "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent", - "status": "open", + "status": "closed", "priority": "medium", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784993980505 + "updatedAt": 1785083912470 }, { "issueRef": "#103", @@ -1078,4 +1078,4 @@ "updatedAt": 1785013507979 } ] -} \ No newline at end of file +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 82fe758..469e797 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 }, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index f760220..a11990f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs index 4b79093..87f93a7 100644 --- a/crates/app-tauri/tests/dto_model_servers.rs +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -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(); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 43800f3..426b1b6 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index 1aa9eba..26b83c5 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -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, + }, +} + +impl From 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, + /// Persisted local model-server configs enriched with artifact state. + pub servers: Vec, } /// Lists local model-server configurations. pub struct ListModelServers { registry: Arc, + downloader: Option>, + downloads: Option>, } impl ListModelServers { /// Builds the use case. #[must_use] pub fn new(registry: Arc) -> 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, + ) -> Self { + self.downloader = Some(downloader); + self + } + + /// Enables in-flight download state enrichment. + #[must_use] + pub fn with_download_tracker( + mut self, + downloads: Arc, + ) -> 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 { - 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 { + 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, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +} + +impl DeleteModelArtifact { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, + ) -> 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::, _>>()?, + ); + 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(), diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 7ffff67..9dcf356 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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 { + 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 { + 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 { + 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 { - let mut all: Vec = 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::>() - }) - .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 { + LiveSessions::live_agent_snapshots(self) + } } diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 4dcffbd..00edd64 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -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, + deleted: Mutex>, } 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 { + 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>, +} + +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, +} + +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 { + 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, StoreError> { + Ok(Vec::new()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + 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 { + 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 { + 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 { + 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) } +fn delete_artifact_usecase( + registry: Arc, + downloader: Arc, + tracker: Arc, + profiles: Arc, +) -> 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, + downloader: Arc, + tracker: Arc, + profiles: Arc, + probe: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +) -> DeleteModelArtifact { + DeleteModelArtifact::new( + registry as Arc, + probe as Arc, + downloader as Arc, + tracker as Arc, + profiles as Arc, + projects as Arc, + contexts as Arc, + live as Arc, + ) +} + fn progress(downloaded: Option, total: Option) -> 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()); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 50b844b..599fda8 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1343,7 +1343,10 @@ impl From 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, + /// 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, + }, +} + +impl Default for ModelArtifactDto { + fn default() -> Self { + Self::NotManaged + } +} + +impl From 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 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 for ModelServerConfigListDto { Self( out.servers .into_iter() - .map(ModelServerConfigDto::from_domain) + .map(ModelServerConfigDto::from) .collect(), ) } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 7a9a199..e63426f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, /// Deletes local model server configurations when unused. pub delete_model_server: Arc, + /// Deletes managed local model artifacts without deleting server configs. + pub delete_model_artifact: Arc, /// 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, @@ -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; let ensure_local_model_server = Arc::new( EnsureLocalModelServer::new( Arc::clone(&model_server_registry) as Arc, - Arc::new(HttpOpenAiCompatibleProbe::default()) - as Arc, + Arc::clone(&model_server_probe_port), Arc::new(LocalManagedProcess::new()) as Arc, Arc::new(LlamaCppRuntime::new()) as Arc, Arc::clone(&fs_port), Arc::clone(&events_port), ) - .with_model_artifact_downloader( - model_artifact_downloader as Arc, - ), + .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader) + as Arc), ); let model_server_registry_port = Arc::clone(&model_server_registry) as Arc; - 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; + let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server) + as Arc; + 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, + )); // 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, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 69f8ba4..587961f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, + }, +} + /// 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; + /// Resolves a Hugging Face model to a local artifact path. /// /// # Errors @@ -1349,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync { progress: Arc, cancel: ModelArtifactCancel, ) -> Result; + + /// 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. diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs index 209dea5..bffbeb6 100644 --- a/crates/infrastructure/src/model_server/mod.rs +++ b/crates/infrastructure/src/model_server/mod.rs @@ -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) -> bool { pub struct HfModelArtifactDownloader { cache_dir: PathBuf, client: reqwest::Client, + repo_locks: Arc>>>>, } 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> { + 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 { + 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 { + 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, cancel: ModelArtifactCancel, ) -> Result { + 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 .map_err(|e| ModelServerError::Invalid(e.to_string())) } +fn file_size(path: &Path) -> Result { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .map_err(|e| ModelServerError::Store(e.to_string())) +} + +fn paths_size(paths: &[PathBuf]) -> Result { + 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; diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs index e5de42e..949509b 100644 --- a/crates/infrastructure/tests/model_server.rs +++ b/crates/infrastructure/tests/model_server.rs @@ -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 + ); +} diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 5fca750..9382dbb 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -229,6 +229,9 @@ export class HttpModelServerGateway implements ModelServerGateway { async deleteModelServer(serverId: string): Promise { await this.http.invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await this.http.invoke("delete_model_artifact", { serverId }); + } previewModelServerCommand(config: LocalModelServerConfig): Promise { return this.http.invoke("preview_model_server_command", { config }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 88f2a27..888b6d2 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1525,6 +1525,36 @@ export class MockModelServerGateway implements ModelServerGateway { this.servers = this.servers.filter((s) => s.id !== serverId); } + async deleteModelArtifact(serverId: string): Promise { + const i = this.servers.findIndex((s) => s.id === serverId); + if (i < 0) { + const err: GatewayError = { + code: "not_configured", + message: "model server is not configured", + }; + throw err; + } + const server = this.servers[i]; + if (server.modelSource?.type !== "huggingFace") { + const err: GatewayError = { + code: "invalid", + message: "only managed Hugging Face model artifacts can be deleted", + }; + throw err; + } + if (server.artifact?.state === "downloading" || this.inUse.has(serverId)) { + const err: GatewayError = { + code: "model_server_in_use", + message: "model artifact cannot be deleted while in use", + }; + throw err; + } + this.servers[i] = { + ...server, + artifact: { state: "missing" }, + }; + } + async previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts index 72a1d75..8905b19 100644 --- a/frontend/src/adapters/modelServer.ts +++ b/frontend/src/adapters/modelServer.ts @@ -32,6 +32,10 @@ export class TauriModelServerGateway implements ModelServerGateway { await invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await invoke("delete_model_artifact", { serverId }); + } + previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 2f14f34..8ff6e81 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -99,6 +99,17 @@ export type ModelSource = | { type: "localPath"; path: string } | { type: "huggingFace"; repo: string }; +/** + * Derived state of an IdeA-managed local model artifact cache (#70, mirror of + * `ModelArtifactDto`, tagged on `state`). Only Hugging Face sources are managed; + * local `.gguf` paths stay `notManaged`. + */ +export type ModelArtifact = + | { state: "notManaged" } + | { state: "missing" } + | { state: "downloading" } + | { state: "downloaded"; path: string; sizeBytes?: number }; + /** * A declared local model server (F35, mirror of the backend flat * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project @@ -138,6 +149,8 @@ export interface LocalModelServerConfig { args: string[]; autoStart: boolean; stopPolicy: StopPolicy; + /** Derived managed artifact cache state. Present on list results. */ + artifact?: ModelArtifact; } /** diff --git a/frontend/src/features/git/git.test.tsx b/frontend/src/features/git/git.test.tsx index b7dd1a7..33eee13 100644 --- a/frontend/src/features/git/git.test.tsx +++ b/frontend/src/features/git/git.test.tsx @@ -59,8 +59,8 @@ describe("GitPanel (with MockGitGateway)", () => { renderPanel(git); await waitForPanel(); - expect(screen.getByText("Staged")).toBeTruthy(); - expect(screen.getByText("Unstaged")).toBeTruthy(); + await screen.findByText("Staged"); + await screen.findByText("Unstaged"); // src/main.rs is staged → Unstage button exists expect( screen.getByRole("button", { name: "unstage src/main.rs" }), diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx index c24116b..ce7eb6d 100644 --- a/frontend/src/features/model-servers/ModelServersPanel.tsx +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react"; import type { LocalModelServerConfig, + ModelArtifact, ModelServerCommandPreview, ModelSource, StopPolicy, @@ -47,6 +48,35 @@ const STOP_POLICIES: { value: StopPolicy; label: string }[] = [ { value: "stopWhenUnused", label: "Stop when unused" }, ]; +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ""; + const units = ["B", "KB", "MB", "GB", "TB"] as const; + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit += 1; + } + const digits = unit === 0 || value >= 10 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unit]}`; +} + +function deleteArtifactConfirmation( + server: LocalModelServerConfig, + artifact: Extract, +): string { + const size = + artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`; + return [ + "Supprimer le modèle téléchargé ?", + `Serveur : ${server.name}`, + "Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", + size, + ] + .filter(Boolean) + .join("\n"); +} + export interface ModelServersPanelProps { /** The model-server registry view-model (from `useModelServers`). */ vm: ModelServersViewModel; @@ -73,6 +103,14 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { if (saved) setDraft(null); } + async function confirmDeleteArtifact( + server: LocalModelServerConfig, + artifact: Extract, + ) { + if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return; + await vm.deleteArtifact(server.id); + } + return ( )} + {vm.notice && ( +

+ {vm.notice} +

+ )} {vm.servers.length === 0 && !draft && (

@@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { )}

    - {vm.servers.map((server) => ( -
  • - - - {server.name} - - - {server.baseURL} · {server.servedModelName} - {server.autoStart ? " · auto-start" : ""} + {vm.servers.map((server) => { + const artifact = server.artifact; + const downloaded = + artifact?.state === "downloaded" ? artifact : undefined; + const deletingArtifact = vm.deletingArtifactId === server.id; + const downloadingArtifact = artifact?.state === "downloading"; + return ( +
  • + + + {server.name} + + + {server.baseURL} · {server.servedModelName} + {server.autoStart ? " · auto-start" : ""} + - - - - void vm.remove(server.id)} - disabled={vm.busy} - > - × - - -
  • - ))} + + {downloadingArtifact && ( + + )} + {downloaded && ( + + )} + + void vm.remove(server.id)} + disabled={vm.busy} + > + × + + + + ); + })}
{draft && ( diff --git a/frontend/src/features/model-servers/modelServers.test.tsx b/frontend/src/features/model-servers/modelServers.test.tsx index f5fdf67..750d392 100644 --- a/frontend/src/features/model-servers/modelServers.test.tsx +++ b/frontend/src/features/model-servers/modelServers.test.tsx @@ -6,7 +6,7 @@ * {@link ModelServerSelect} binding dropdown. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react"; import type { Gateways } from "@/ports"; @@ -312,6 +312,95 @@ describe("ModelServersPanel wizard (F35 V2)", () => { expect(screen.getByLabelText("edit Local A")).toBeTruthy(); }); + it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { + state: "downloaded", + path: "/cache/unsloth/Qwen3.5-9B-GGUF/model.gguf", + sizeBytes: 1_500_000_000, + }, + }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("delete downloaded model Local A")); + + await waitFor(() => { + expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/)); + }); + await waitFor(async () => { + const [server] = await modelServer.listModelServers(); + expect(server).toMatchObject({ + id: SERVER.id, + artifact: { state: "missing" }, + }); + }); + expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy(); + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + + confirm.mockRestore(); + }); + + it("does not offer model artifact deletion for local .gguf or missing managed artifacts", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { state: "missing" }, + }); + await modelServer.saveModelServer({ + ...SERVER, + id: "550e8400-e29b-41d4-a716-446655440001", + name: "Local file", + modelSource: { type: "localPath", path: "/models/qwen.gguf" }, + artifact: { state: "notManaged" }, + }); + renderPanel(modelServer); + + await screen.findByLabelText("edit Local A"); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull(); + }); + + it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { state: "downloading" }, + }); + renderPanel(modelServer); + + const downloading = await screen.findByLabelText("download in progress Local A"); + expect((downloading as HTMLButtonElement).disabled).toBe(true); + expect(downloading.textContent).toContain("Téléchargement en cours"); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + }); + + it("shows a short inline error when artifact deletion is blocked", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { + state: "downloaded", + path: "/cache/model.gguf", + }, + }); + modelServer.markInUse(SERVER.id); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("delete downloaded model Local A")); + + expect((await screen.findByRole("alert")).textContent).toMatch( + /téléchargement en cours ou agent actif/i, + ); + expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy(); + + confirm.mockRestore(); + }); + it("edits an existing server's served model name", async () => { const modelServer = new MockModelServerGateway(); await modelServer.saveModelServer(SERVER); diff --git a/frontend/src/features/model-servers/useModelServers.ts b/frontend/src/features/model-servers/useModelServers.ts index b1bc6b9..f44e668 100644 --- a/frontend/src/features/model-servers/useModelServers.ts +++ b/frontend/src/features/model-servers/useModelServers.ts @@ -24,14 +24,20 @@ export interface ModelServersViewModel { servers: LocalModelServerConfig[]; /** Last error message, or `null`. */ error: string | null; + /** Last non-blocking success message, or `null`. */ + notice: string | null; /** Whether a request is in flight. */ busy: boolean; + /** Server id whose managed artifact is currently being deleted, or `null`. */ + deletingArtifactId: string | null; /** Reloads the server list. */ reload: () => Promise; /** Creates or updates a server; returns the persisted config (or `null` on error). */ save: (config: LocalModelServerConfig) => Promise; /** Deletes a server by id; returns `true` on success. */ remove: (serverId: string) => Promise; + /** Deletes only the managed downloaded model artifact; returns `true` on success. */ + deleteArtifact: (serverId: string) => Promise; /** * Asks the backend to build the `llama-server` command line for a draft * (never reconstructed client-side). Returns `null` when the draft is @@ -62,11 +68,14 @@ export function useModelServers(): ModelServersViewModel { const { modelServer } = useGateways(); const [servers, setServers] = useState([]); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); + const [deletingArtifactId, setDeletingArtifactId] = useState(null); const reload = useCallback(async () => { setBusy(true); setError(null); + setNotice(null); try { setServers(await modelServer.listModelServers()); } catch (e) { @@ -84,6 +93,7 @@ export function useModelServers(): ModelServersViewModel { async (config: LocalModelServerConfig) => { setBusy(true); setError(null); + setNotice(null); try { const saved = await modelServer.saveModelServer(config); setServers((prev) => { @@ -110,6 +120,7 @@ export function useModelServers(): ModelServersViewModel { async (serverId: string) => { setBusy(true); setError(null); + setNotice(null); try { await modelServer.deleteModelServer(serverId); setServers((prev) => prev.filter((s) => s.id !== serverId)); @@ -132,6 +143,37 @@ export function useModelServers(): ModelServersViewModel { [modelServer], ); + const deleteArtifact = useCallback( + async (serverId: string) => { + setBusy(true); + setDeletingArtifactId(serverId); + setError(null); + setNotice(null); + try { + await modelServer.deleteModelArtifact(serverId); + setServers(await modelServer.listModelServers()); + setNotice("Modèle téléchargé supprimé. Le serveur reste configuré."); + return true; + } catch (e) { + const code = codeOf(e); + if (code === "model_server_in_use") { + setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif."); + } else if (code === "invalid") { + setError("Aucun modèle téléchargé géré à supprimer."); + } else if (code === "not_configured") { + setError("Serveur introuvable."); + } else { + setError(describe(e)); + } + return false; + } finally { + setDeletingArtifactId(null); + setBusy(false); + } + }, + [modelServer], + ); + const preview = useCallback( async (config: LocalModelServerConfig) => { try { @@ -145,7 +187,22 @@ export function useModelServers(): ModelServersViewModel { [modelServer], ); - const clearError = useCallback(() => setError(null), []); + const clearError = useCallback(() => { + setError(null); + setNotice(null); + }, []); - return { servers, error, busy, reload, save, remove, preview, clearError }; + return { + servers, + error, + notice, + busy, + deletingArtifactId, + reload, + save, + remove, + deleteArtifact, + preview, + clearError, + }; } diff --git a/frontend/src/features/terminals/TerminalView.scrollback.test.tsx b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx new file mode 100644 index 0000000..2341f90 --- /dev/null +++ b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { MockTerminalGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; + +const terminalOptions: unknown[] = []; + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + readonly rows = 24; + readonly cols = 80; + + constructor(options: unknown) { + terminalOptions.push(options); + } + + loadAddon() {} + open() {} + onData() { + return { dispose() {} }; + } + write() {} + input() {} + focus() {} + dispose() {} + }, +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() {} + }, +})); + +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +import { + TerminalView, + TERMINAL_SCROLLBACK_LINES, +} from "./TerminalView"; + +describe("TerminalView scrollback", () => { + it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => { + const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways; + + render( + + + , + ); + + expect(terminalOptions[0]).toMatchObject({ + scrollback: TERMINAL_SCROLLBACK_LINES, + }); + expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 01fcf1b..5ba23e4 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -434,6 +434,27 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = fitSpy.mockRestore(); }); + it("refits after window restore/focus without a new refitSignal", async () => { + const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); + const open = vi.fn(async () => makeHandle({ sessionId: "restore-1" })); + + renderView(new MockTerminalGateway(), "/cwd", { + open, + refitSignal: 1, + }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + setTerminalBoxSize(400, 200); + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); + fitSpy.mockClear(); + + window.dispatchEvent(new Event("focus")); + + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); + expect(open).toHaveBeenCalledTimes(1); + + fitSpy.mockRestore(); + }); + it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => { const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" })); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 6ef7b7f..015067f 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { useGateways } from "@/app/di"; +import type { ResolvedAgentSystemPermissions } from "@/domain"; import type { OpenTerminalOptions, ReattachResult, @@ -46,6 +47,12 @@ import type { WritePortal, } from "@/ports"; +// The backend PTY retains a bounded byte tail for reattach (~100 KB today), but +// xterm also has its own viewport history. Its default is too shallow for chatty +// OpenCode TUIs, which made the visible cell stop scrolling long before the +// retained terminal output was exhausted. +export const TERMINAL_SCROLLBACK_LINES = 10_000; + interface TerminalViewProps { /** Working directory the shell opens in (typically the project root). */ cwd: string; @@ -113,6 +120,8 @@ interface TerminalViewProps { * it never remounts/reopens the terminal. */ refitSignal?: number; + /** Optional resolved system permissions for this agent/cell. */ + systemPermissions?: ResolvedAgentSystemPermissions | null; } /** @@ -144,6 +153,7 @@ export function TerminalView({ portal, onReady, refitSignal, + systemPermissions, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -181,7 +191,7 @@ export function TerminalView({ // Holds the mounted instance's `refit` closure so the `refitSignal` effect // below (a separate effect, since it must NOT re-run/reopen the terminal on // every parent render) can trigger it without depending on `cwd`'s effect. - const refitRef = useRef<(() => void) | null>(null); + const refitRef = useRef<((settleFrames?: number) => void) | null>(null); useEffect(() => { const container = containerRef.current; @@ -200,6 +210,7 @@ export function TerminalView({ fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + scrollback: TERMINAL_SCROLLBACK_LINES, }); const fit = new FitAddon(); term.loadAddon(fit); @@ -218,6 +229,7 @@ export function TerminalView({ let lastRows = term.rows; let lastCols = term.cols; let hasUsefulFit = false; + let settleFramesRemaining = 0; // Keystroke → PTY path. The agent cell is a **native terminal** // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell. @@ -349,6 +361,13 @@ export function TerminalView({ // now reschedules on the next few frames instead of abandoning — bounded, // so a container that is genuinely never laid out (e.g. headless tests) // doesn't spin forever. + // A successful fit can also land on a non-zero but still intermediate box + // during project/layout switches, split/merge commits, re-attach, and OS + // minimize/restore. Keep a small coalesced tail of fits on following frames + // so the final settled geometry is pushed automatically without requiring a + // manual resize. This stays bounded and preserves the rows/cols-changed + // guard before touching the PTY. + const SETTLE_REFIT_FRAMES = 4; const MAX_ZERO_SIZE_RETRIES = 8; let zeroSizeRetries = 0; const refit = () => { @@ -382,14 +401,29 @@ export function TerminalView({ } else if (isFirstUsefulFit) { resizeHandleToCurrentGeometry(); } + + if (settleFramesRemaining > 0) { + settleFramesRemaining -= 1; + rafId = requestAnimationFrame(refit); + } }; - const scheduleRefit = () => { - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(refit); + const scheduleRefit = (settleFrames = 0) => { + settleFramesRemaining = Math.max(settleFramesRemaining, settleFrames); + if (!rafId) rafId = requestAnimationFrame(refit); }; - const ro = new ResizeObserver(scheduleRefit); + const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_FRAMES); + const scheduleVisibleRefit = () => { + if (document.visibilityState === "hidden") return; + scheduleSettledRefit(); + }; + const ro = new ResizeObserver(() => scheduleRefit()); ro.observe(container); - scheduleRefit(); + scheduleSettledRefit(); + window.addEventListener("resize", scheduleSettledRefit); + window.addEventListener("focus", scheduleSettledRefit); + window.addEventListener("pageshow", scheduleSettledRefit); + document.addEventListener("visibilitychange", scheduleVisibleRefit); + window.visualViewport?.addEventListener("resize", scheduleSettledRefit); // Let the `refitSignal` effect below trigger the SAME coalesced refit after // a structural layout mutation (split/merge, ticket #61) — surviving cells // don't always get a timely useful ResizeObserver event from a sibling @@ -401,6 +435,11 @@ export function TerminalView({ refitRef.current = null; if (rafId) cancelAnimationFrame(rafId); ro.disconnect(); + window.removeEventListener("resize", scheduleSettledRefit); + window.removeEventListener("focus", scheduleSettledRefit); + window.removeEventListener("pageshow", scheduleSettledRefit); + document.removeEventListener("visibilitychange", scheduleVisibleRefit); + window.visualViewport?.removeEventListener("resize", scheduleSettledRefit); onKey.dispose(); portalRef.current?.unbindHandle(); // DETACH, never close: tearing the view down (navigation / layout change) @@ -425,9 +464,18 @@ export function TerminalView({ // logic. useEffect(() => { if (refitSignal === undefined) return; - refitRef.current?.(); + refitRef.current?.(4); }, [refitSignal]); + const showNetworkBanner = + systemPermissions != null && + (systemPermissions.runtimeLock.state === "locked" || + systemPermissions.effective === "deny"); + const networkReason = + systemPermissions?.runtimeLock.reason ?? + systemPermissions?.control.reason ?? + "Le réseau est interdit pour cette cellule."; + return (
+ {showNetworkBanner && ( +
+ {systemPermissions.runtimeLock.state === "locked" + ? "Réseau verrouillé par le runtime." + : "Réseau interdit pour cet agent."}{" "} + {networkReason} +
+ )} {!terminalReady && !openError && (
; + /** + * Deletes the IdeA-managed downloaded model artifact for a server while + * keeping the server config. Rejects with `invalid` for local `.gguf` paths, + * `model_server_in_use` while downloading or used by a live agent, and + * `not_configured` when the server no longer exists. + */ + deleteModelArtifact(serverId: string): Promise; /** * Builds the `llama-server` command line the backend would launch for the * draft config, without persisting it. The backend is the sole authority on