- #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:
@ -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
|
||||
|
||||
51
.ideai/memory/tickets-70-100-102-ux-surface-scoping.md
Normal file
51
.ideai/memory/tickets-70-100-102-ux-surface-scoping.md
Normal file
@ -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é : <raison courte>`.
|
||||
|
||||
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é : <taille>.`
|
||||
- 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.
|
||||
@ -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.
|
||||
Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule.
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
@ -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
|
||||
Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 104
|
||||
"nextNumber": 107
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1339,6 +1339,7 @@ pub async fn save_model_server(
|
||||
.map_err(ErrorDto::from)?
|
||||
.servers
|
||||
.into_iter()
|
||||
.map(|item| item.config)
|
||||
.find(|config| config.id == server_id);
|
||||
let input = save_model_server_input(request, existing.as_ref())?;
|
||||
state
|
||||
@ -1388,6 +1389,26 @@ pub async fn delete_model_server(
|
||||
.map_err(model_server_command_error)
|
||||
}
|
||||
|
||||
/// `delete_model_artifact` — delete a managed downloaded model artifact while
|
||||
/// keeping the local model-server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use`
|
||||
/// when a download or live agent blocks deletion, and model-server errors for
|
||||
/// cache I/O failures.
|
||||
#[tauri::command]
|
||||
pub async fn delete_model_artifact(
|
||||
server_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let server_id = parse_model_server_id(&server_id)?;
|
||||
state
|
||||
.delete_model_artifact
|
||||
.execute(application::DeleteModelArtifactInput { server_id })
|
||||
.await
|
||||
.map_err(model_server_command_error)
|
||||
}
|
||||
|
||||
fn model_server_command_error(err: AppError) -> ErrorDto {
|
||||
match err {
|
||||
AppError::ModelServer { code, message } => ErrorDto { code, message },
|
||||
|
||||
@ -265,6 +265,7 @@ pub fn run() {
|
||||
commands::save_model_server,
|
||||
commands::preview_model_server_command,
|
||||
commands::delete_model_server,
|
||||
commands::delete_model_artifact,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
commands::delete_embedder_profile,
|
||||
|
||||
@ -132,6 +132,7 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
auto_start: true,
|
||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||
warmup_deadline_secs: Some(900),
|
||||
artifact: Default::default(),
|
||||
};
|
||||
|
||||
let config = dto.into_domain(Some(&existing)).unwrap();
|
||||
|
||||
@ -127,10 +127,12 @@ pub use memory::{
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use model_server::{
|
||||
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||
SaveModelServerInput, SaveModelServerOutput,
|
||||
model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer,
|
||||
DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput,
|
||||
ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem,
|
||||
ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -11,33 +11,101 @@ use domain::model_server::{
|
||||
ModelSource,
|
||||
};
|
||||
use domain::ports::{
|
||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
|
||||
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
ProfileStore, ProjectStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
/// Artifact cache state exposed by model-server list use cases.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelArtifactView {
|
||||
/// The configured source is not managed by IdeA's downloader.
|
||||
NotManaged,
|
||||
/// The configured source is managed but not present in cache.
|
||||
Missing,
|
||||
/// A download/prepare operation is currently running for this server.
|
||||
Downloading,
|
||||
/// The configured source is present in cache.
|
||||
Downloaded {
|
||||
/// Local artifact path used by llama.cpp.
|
||||
path: String,
|
||||
/// Total on-disk size when known.
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ModelArtifactState> for ModelArtifactView {
|
||||
fn from(state: ModelArtifactState) -> Self {
|
||||
match state {
|
||||
ModelArtifactState::NotManaged => Self::NotManaged,
|
||||
ModelArtifactState::Missing => Self::Missing,
|
||||
ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded {
|
||||
path: path.as_str().to_owned(),
|
||||
size_bytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured local model server plus derived artifact state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelServerListItem {
|
||||
/// Persisted local model-server config.
|
||||
pub config: LocalModelServerConfig,
|
||||
/// Derived artifact cache state.
|
||||
pub artifact: ModelArtifactView,
|
||||
}
|
||||
|
||||
/// Output of [`ListModelServers::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListModelServersOutput {
|
||||
/// Persisted local model-server configs.
|
||||
pub servers: Vec<LocalModelServerConfig>,
|
||||
/// Persisted local model-server configs enriched with artifact state.
|
||||
pub servers: Vec<ModelServerListItem>,
|
||||
}
|
||||
|
||||
/// Lists local model-server configurations.
|
||||
pub struct ListModelServers {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
downloader: Option<Arc<dyn ModelArtifactDownloader>>,
|
||||
downloads: Option<Arc<dyn ModelArtifactDownloadTracker>>,
|
||||
}
|
||||
|
||||
impl ListModelServers {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||
Self { registry }
|
||||
Self {
|
||||
registry,
|
||||
downloader: None,
|
||||
downloads: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables artifact state enrichment for Hugging Face-backed servers.
|
||||
#[must_use]
|
||||
pub fn with_model_artifact_downloader(
|
||||
mut self,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
) -> Self {
|
||||
self.downloader = Some(downloader);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables in-flight download state enrichment.
|
||||
#[must_use]
|
||||
pub fn with_download_tracker(
|
||||
mut self,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
) -> Self {
|
||||
self.downloads = Some(downloads);
|
||||
self
|
||||
}
|
||||
|
||||
/// Lists configs.
|
||||
@ -45,10 +113,44 @@ impl ListModelServers {
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] on registry failure.
|
||||
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
|
||||
Ok(ListModelServersOutput {
|
||||
servers: self.registry.list().await?,
|
||||
})
|
||||
let configs = self.registry.list().await?;
|
||||
let mut servers = Vec::with_capacity(configs.len());
|
||||
for config in configs {
|
||||
let artifact = self.artifact_view(&config).await?;
|
||||
servers.push(ModelServerListItem { config, artifact });
|
||||
}
|
||||
Ok(ListModelServersOutput { servers })
|
||||
}
|
||||
|
||||
async fn artifact_view(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<ModelArtifactView, AppError> {
|
||||
if self
|
||||
.downloads
|
||||
.as_ref()
|
||||
.is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id))
|
||||
{
|
||||
return Ok(ModelArtifactView::Downloading);
|
||||
}
|
||||
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
||||
return Ok(ModelArtifactView::NotManaged);
|
||||
};
|
||||
let Some(downloader) = self.downloader.as_ref() else {
|
||||
return Ok(ModelArtifactView::Missing);
|
||||
};
|
||||
downloader
|
||||
.hf_model_state(repo)
|
||||
.await
|
||||
.map(ModelArtifactView::from)
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only in-flight download state shared by list/delete use cases.
|
||||
pub trait ModelArtifactDownloadTracker: Send + Sync {
|
||||
/// Whether the model artifact for `server_id` is currently being resolved/downloaded.
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool;
|
||||
}
|
||||
|
||||
/// Input for [`SaveModelServer::execute`].
|
||||
@ -132,6 +234,154 @@ impl DeleteModelServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteModelArtifact::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteModelArtifactInput {
|
||||
/// Config id whose managed artifact cache should be deleted.
|
||||
pub server_id: LocalModelServerId,
|
||||
}
|
||||
|
||||
/// Deletes a downloaded model artifact without deleting the server config.
|
||||
pub struct DeleteModelArtifact {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
}
|
||||
|
||||
impl DeleteModelArtifact {
|
||||
/// Builds the use case.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
probe,
|
||||
downloader,
|
||||
downloads,
|
||||
profiles,
|
||||
projects,
|
||||
contexts,
|
||||
live,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a managed Hugging Face artifact after safety checks.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] when the server is missing, the source is not
|
||||
/// deletable, a download is active, or a live agent uses the server.
|
||||
pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> {
|
||||
let config = self
|
||||
.registry
|
||||
.get(&input.server_id)
|
||||
.await?
|
||||
.ok_or(ModelServerError::NotConfigured)?;
|
||||
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
||||
return Err(ModelServerError::Invalid(
|
||||
"only managed Hugging Face model artifacts can be deleted".to_owned(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
if self
|
||||
.downloads
|
||||
.is_model_artifact_download_in_progress(input.server_id)
|
||||
{
|
||||
return Err(ModelServerError::InUse(format!(
|
||||
"model artifact download in progress for {}",
|
||||
input.server_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
self.ensure_server_not_reachable(&config).await?;
|
||||
self.ensure_not_used_by_live_agent(input.server_id).await?;
|
||||
self.downloader.delete_hf_model(repo).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_server_not_reachable(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
match self.probe.probe(&config.endpoint).await? {
|
||||
ModelServerStatus::Unreachable => Ok(()),
|
||||
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
||||
Err(ModelServerError::InUse(format!(
|
||||
"model server {} is currently reachable",
|
||||
config.id
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_not_used_by_live_agent(
|
||||
&self,
|
||||
server_id: LocalModelServerId,
|
||||
) -> Result<(), AppError> {
|
||||
let profiles = self.profiles.list().await?;
|
||||
let profile_server: HashMap<_, _> = profiles
|
||||
.iter()
|
||||
.filter_map(|profile| {
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|opencode| opencode.local_model_server_id)
|
||||
.map(|id| (profile.id, id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut agents_by_project = HashMap::new();
|
||||
for snapshot in self.live.live_agent_snapshots() {
|
||||
let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) {
|
||||
agents
|
||||
} else {
|
||||
let project = self.projects.load_project(snapshot.project_id).await?;
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
agents_by_project.insert(
|
||||
snapshot.project_id,
|
||||
manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
);
|
||||
agents_by_project
|
||||
.get(&snapshot.project_id)
|
||||
.expect("project agents inserted")
|
||||
};
|
||||
let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else {
|
||||
continue;
|
||||
};
|
||||
if profile_server.get(&agent.profile_id) == Some(&server_id) {
|
||||
return Err(ModelServerError::InUse(format!(
|
||||
"model server {server_id} is used by live agent {}",
|
||||
agent.id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`EnsureLocalModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnsureLocalModelServerInput {
|
||||
@ -655,6 +905,15 @@ impl EnsureLocalModelServer {
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
||||
self.download_cancels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(&server_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
|
||||
ModelServerReady {
|
||||
base_url: config.endpoint.base_url.clone(),
|
||||
|
||||
@ -64,6 +64,11 @@ pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
|
||||
/// would be wrongly marked as still running).
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool;
|
||||
|
||||
/// Snapshots every live agent session currently known by this registry.
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
@ -89,6 +94,26 @@ impl LiveAgentRegistry for TerminalSessions {
|
||||
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
@ -426,6 +451,23 @@ impl LiveAgentRegistry for StructuredSessions {
|
||||
.map(|m| m.values().any(|e| e.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredSessions {
|
||||
@ -819,42 +861,8 @@ impl LiveSessions {
|
||||
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
|
||||
#[must_use]
|
||||
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
let mut all: Vec<LiveSessionSnapshot> = self
|
||||
.pty
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
all.extend(
|
||||
self.structured
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let mut all = self.pty.live_agent_snapshots();
|
||||
all.extend(self.structured.live_agent_snapshots());
|
||||
all
|
||||
}
|
||||
}
|
||||
@ -868,4 +876,8 @@ impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
LiveSessions::live_agent_snapshots(self)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,28 +7,52 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
ModelServerReadinessPolicy,
|
||||
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
|
||||
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
|
||||
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy,
|
||||
};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
|
||||
ModelServerStatus, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
AgentContextStore, DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess,
|
||||
ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, ModelArtifactState, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore,
|
||||
ProjectStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||
use domain::project::Project;
|
||||
use domain::{
|
||||
AgentId, AgentManifest, LocalModelServerId, ManifestEntry, NodeId, ProfileId, ProjectId,
|
||||
ProjectPath, RemoteRef, SessionId,
|
||||
};
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sess(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(
|
||||
id: LocalModelServerId,
|
||||
port: u16,
|
||||
@ -294,18 +318,27 @@ enum FakeDownloadOutcome {
|
||||
|
||||
struct FakeModelArtifactDownloader {
|
||||
outcome: Mutex<FakeDownloadOutcome>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeModelArtifactDownloader {
|
||||
fn new(outcome: FakeDownloadOutcome) -> Self {
|
||||
Self {
|
||||
outcome: Mutex::new(outcome),
|
||||
deleted: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
_repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -347,6 +380,123 @@ impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
self.deleted.lock().unwrap().push(repo.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeDownloadTracker {
|
||||
in_progress: Mutex<Vec<LocalModelServerId>>,
|
||||
}
|
||||
|
||||
impl ModelArtifactDownloadTracker for FakeDownloadTracker {
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
||||
self.in_progress.lock().unwrap().contains(&server_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeLive {
|
||||
snapshots: Vec<LiveSessionSnapshot>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for FakeLive {
|
||||
fn is_agent_live(&self, _project_id: ProjectId, _agent_id: &AgentId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<application::LiveSessionSnapshot> {
|
||||
self.snapshots.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProjects {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
impl Default for FakeProjects {
|
||||
fn default() -> Self {
|
||||
Self { project_id: pid(1) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
if id != self.project_id {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Project::new(
|
||||
id,
|
||||
"Test",
|
||||
ProjectPath::new("/tmp/unused").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.map_err(|err| StoreError::Invalid(err.to_string()))
|
||||
}
|
||||
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace {
|
||||
windows: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeContexts {
|
||||
manifest: AgentManifest,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
Ok(MarkdownDoc::new(""))
|
||||
}
|
||||
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
_md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -427,6 +577,46 @@ fn ensure_with_downloader(
|
||||
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
) -> DeleteModelArtifact {
|
||||
delete_artifact_usecase_with_live(
|
||||
registry,
|
||||
downloader,
|
||||
tracker,
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase_with_live(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
probe: Arc<FakeProbe>,
|
||||
projects: Arc<FakeProjects>,
|
||||
contexts: Arc<FakeContexts>,
|
||||
live: Arc<FakeLive>,
|
||||
) -> DeleteModelArtifact {
|
||||
DeleteModelArtifact::new(
|
||||
registry as Arc<dyn ModelServerRegistry>,
|
||||
probe as Arc<dyn ModelServerProbe>,
|
||||
downloader as Arc<dyn ModelArtifactDownloader>,
|
||||
tracker as Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles as Arc<dyn ProfileStore>,
|
||||
projects as Arc<dyn ProjectStore>,
|
||||
contexts as Arc<dyn AgentContextStore>,
|
||||
live as Arc<dyn LiveAgentRegistry>,
|
||||
)
|
||||
}
|
||||
|
||||
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
|
||||
ModelArtifactProgress {
|
||||
downloaded_bytes: downloaded,
|
||||
@ -1325,3 +1515,218 @@ async fn delete_model_server_removes_unused_config() {
|
||||
|
||||
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_local_path_source() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(25), 8105, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/model.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(25) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"),
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
assert!(registry.get(&sid(25)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_download_in_progress() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let tracker = Arc::new(FakeDownloadTracker::default());
|
||||
tracker.in_progress.lock().unwrap().push(sid(26));
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
tracker,
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(26) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_live_agent_using_server_profile() {
|
||||
let server_id = sid(27);
|
||||
let project_id = pid(27);
|
||||
let agent_id = aid(27);
|
||||
let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270));
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(
|
||||
profile_id.as_uuid().as_u128(),
|
||||
server_id,
|
||||
)])));
|
||||
let contexts = Arc::new(FakeContexts {
|
||||
manifest: AgentManifest::new(
|
||||
1,
|
||||
vec![ManifestEntry::new(
|
||||
agent_id,
|
||||
"Local Agent",
|
||||
"agents/local.md",
|
||||
profile_id,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap(),
|
||||
});
|
||||
let live = Arc::new(FakeLive {
|
||||
snapshots: vec![LiveSessionSnapshot {
|
||||
project_id,
|
||||
agent_id,
|
||||
node_id: nid(27),
|
||||
session_id: sess(27),
|
||||
kind: LiveSessionKind::Pty,
|
||||
}],
|
||||
});
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects { project_id }),
|
||||
contexts,
|
||||
live,
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_reachable_server_endpoint() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(28) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(27) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
downloader.deleted.lock().unwrap().as_slice(),
|
||||
["Qwen/Qwen3-Coder:Q4_K_M"]
|
||||
);
|
||||
assert!(registry.get(&sid(27)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
@ -1343,7 +1343,10 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
// Local model servers (B35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use application::{
|
||||
ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
@ -1492,6 +1495,9 @@ pub struct ModelServerConfigDto {
|
||||
/// Optional readiness warmup deadline override in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warmup_deadline_secs: Option<u64>,
|
||||
/// Derived local artifact cache state.
|
||||
#[serde(default)]
|
||||
pub artifact: ModelArtifactDto,
|
||||
}
|
||||
|
||||
impl ModelServerConfigDto {
|
||||
@ -1516,6 +1522,7 @@ impl ModelServerConfigDto {
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
warmup_deadline_secs: config.warmup_deadline_secs,
|
||||
artifact: ModelArtifactDto::NotManaged,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1563,6 +1570,53 @@ impl ModelServerConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local model artifact cache state on the IPC wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum ModelArtifactDto {
|
||||
/// The configured source is not managed by IdeA's downloader.
|
||||
NotManaged,
|
||||
/// The configured source is managed but not present in cache.
|
||||
Missing,
|
||||
/// A download/prepare operation is currently running for this server.
|
||||
Downloading,
|
||||
/// The configured source is present in cache.
|
||||
Downloaded {
|
||||
/// Local artifact path.
|
||||
path: String,
|
||||
/// Total on-disk size when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ModelArtifactDto {
|
||||
fn default() -> Self {
|
||||
Self::NotManaged
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelArtifactView> for ModelArtifactDto {
|
||||
fn from(view: ModelArtifactView) -> Self {
|
||||
match view {
|
||||
ModelArtifactView::NotManaged => Self::NotManaged,
|
||||
ModelArtifactView::Missing => Self::Missing,
|
||||
ModelArtifactView::Downloading => Self::Downloading,
|
||||
ModelArtifactView::Downloaded { path, size_bytes } => {
|
||||
Self::Downloaded { path, size_bytes }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerListItem> for ModelServerConfigDto {
|
||||
fn from(item: ModelServerListItem) -> Self {
|
||||
let mut dto = Self::from_domain(item.config);
|
||||
dto.artifact = item.artifact.into();
|
||||
dto
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `preview_model_server_command`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1585,7 +1639,7 @@ impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||
Self(
|
||||
out.servers
|
||||
.into_iter()
|
||||
.map(ModelServerConfigDto::from_domain)
|
||||
.map(ModelServerConfigDto::from)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,15 +19,15 @@ use application::{
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
@ -59,8 +59,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
@ -965,6 +965,8 @@ pub struct BackendCore {
|
||||
pub save_model_server: Arc<SaveModelServer>,
|
||||
/// Deletes local model server configurations when unused.
|
||||
pub delete_model_server: Arc<DeleteModelServer>,
|
||||
/// Deletes managed local model artifacts without deleting server configs.
|
||||
pub delete_model_artifact: Arc<DeleteModelArtifact>,
|
||||
/// The local PTY adapter, kept port-typed so driving adapters can subscribe
|
||||
/// output and route it through their own transport bridge.
|
||||
pub pty_port: Arc<dyn PtyPort>,
|
||||
@ -1513,25 +1515,31 @@ impl BackendCore {
|
||||
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
|
||||
app_data_dir.join("hf-model-artifacts"),
|
||||
));
|
||||
let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>;
|
||||
let ensure_local_model_server = Arc::new(
|
||||
EnsureLocalModelServer::new(
|
||||
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
|
||||
Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>,
|
||||
Arc::clone(&model_server_probe_port),
|
||||
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
|
||||
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
)
|
||||
.with_model_artifact_downloader(
|
||||
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
|
||||
),
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader)
|
||||
as Arc<dyn domain::ports::ModelArtifactDownloader>),
|
||||
);
|
||||
let model_server_registry_port =
|
||||
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
|
||||
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
let model_artifact_downloader_port =
|
||||
Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
|
||||
let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
|
||||
as Arc<dyn application::ModelArtifactDownloadTracker>;
|
||||
let list_model_servers = Arc::new(
|
||||
ListModelServers::new(Arc::clone(&model_server_registry_port))
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
|
||||
.with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
|
||||
);
|
||||
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
@ -2387,6 +2395,16 @@ impl BackendCore {
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&structured_sessions),
|
||||
));
|
||||
let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
|
||||
Arc::clone(&model_server_registry_port),
|
||||
Arc::clone(&model_server_probe_port),
|
||||
Arc::clone(&model_artifact_downloader_port),
|
||||
Arc::clone(&model_artifact_download_tracker),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
|
||||
));
|
||||
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
|
||||
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
|
||||
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
|
||||
@ -2699,6 +2717,7 @@ impl BackendCore {
|
||||
list_model_servers,
|
||||
save_model_server,
|
||||
delete_model_server,
|
||||
delete_model_artifact,
|
||||
pty_port,
|
||||
terminal_sessions,
|
||||
event_bus,
|
||||
|
||||
@ -1311,6 +1311,22 @@ pub struct ModelArtifactResolution {
|
||||
pub cache_hit: bool,
|
||||
}
|
||||
|
||||
/// Cache state for a model artifact managed by IdeA.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelArtifactState {
|
||||
/// Artifact source is not managed by the downloader.
|
||||
NotManaged,
|
||||
/// Managed artifact is not present in cache.
|
||||
Missing,
|
||||
/// Managed artifact is present in cache.
|
||||
Downloaded {
|
||||
/// Local path used to launch the model.
|
||||
path: ModelPath,
|
||||
/// Total on-disk bytes when known.
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cooperative cancellation token for model artifact resolution.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelArtifactCancel {
|
||||
@ -1339,6 +1355,15 @@ impl ModelArtifactCancel {
|
||||
/// Resolves or downloads a model artifact before starting a model server.
|
||||
#[async_trait]
|
||||
pub trait ModelArtifactDownloader: Send + Sync {
|
||||
/// Returns the current cache state for a Hugging Face model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when the cache cannot be inspected.
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError>;
|
||||
|
||||
/// Resolves a Hugging Face model to a local artifact path.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1349,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync {
|
||||
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError>;
|
||||
|
||||
/// Deletes the cached artifact for a Hugging Face model reference.
|
||||
///
|
||||
/// Deleting a missing artifact is a successful no-op.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when deletion fails.
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -10,6 +10,7 @@ use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
@ -17,9 +18,9 @@ use domain::model_server::{
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||
SpawnSpec,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
||||
|
||||
@ -76,6 +77,7 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
pub struct HfModelArtifactDownloader {
|
||||
cache_dir: PathBuf,
|
||||
client: reqwest::Client,
|
||||
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl HfModelArtifactDownloader {
|
||||
@ -85,6 +87,7 @@ impl HfModelArtifactDownloader {
|
||||
Self {
|
||||
cache_dir: cache_dir.into(),
|
||||
client: reqwest::Client::new(),
|
||||
repo_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +152,62 @@ impl HfModelArtifactDownloader {
|
||||
std::fs::write(manifest_path, json)
|
||||
}
|
||||
|
||||
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
|
||||
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(repo.as_str().to_owned())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(file_size(&merged_path)?),
|
||||
path: model_path_from_pathbuf(merged_path)?,
|
||||
});
|
||||
}
|
||||
if let Some(paths) = self.cached_shard_set(repo) {
|
||||
if let Some(first) = paths.first() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(paths_size(&paths)?),
|
||||
path: model_path_from_pathbuf(first.clone())?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
remove_file_if_exists(&merged_path)?;
|
||||
}
|
||||
|
||||
let manifest_path = self.manifest_path_for(repo);
|
||||
if let Ok(raw) = std::fs::read(&manifest_path) {
|
||||
let manifest: ShardManifest =
|
||||
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
let dir = self.cache_dir_for(repo);
|
||||
for filename in manifest.files {
|
||||
remove_file_if_exists(&dir.join(filename))?;
|
||||
}
|
||||
remove_file_if_exists(&manifest_path)?;
|
||||
}
|
||||
|
||||
let repo_dir = self.cache_dir_for(repo);
|
||||
if repo_dir.is_dir()
|
||||
&& std::fs::read_dir(&repo_dir)
|
||||
.map(is_empty_dir)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_remote_filenames(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -189,12 +248,23 @@ struct ShardManifest {
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.cached_state(repo)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
cache_hit: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.delete_cached(repo)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError>
|
||||
.map_err(|e| ModelServerError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
|
||||
std::fs::metadata(path)
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))
|
||||
}
|
||||
|
||||
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| file_size(path))
|
||||
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(ModelServerError::Store(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
|
||||
entries.into_iter().next().is_none()
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::model_server::{
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
|
||||
ModelServerRuntime, RemotePath,
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactState,
|
||||
ModelServerRegistry, ModelServerRuntime, RemotePath,
|
||||
};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{
|
||||
@ -227,3 +227,41 @@ async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_wit
|
||||
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_reports_downloaded_cache_state() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
let state = downloader.hf_model_state(&repo).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state,
|
||||
ModelArtifactState::Downloaded {
|
||||
path: ModelPath::new(path.to_string_lossy()).unwrap(),
|
||||
size_bytes: Some(4),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_deletes_merged_cache_without_config_side_effects() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
downloader.delete_hf_model(&repo).await.unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
downloader.hf_model_state(&repo).await.unwrap(),
|
||||
ModelArtifactState::Missing
|
||||
);
|
||||
}
|
||||
|
||||
@ -229,6 +229,9 @@ export class HttpModelServerGateway implements ModelServerGateway {
|
||||
async deleteModelServer(serverId: string): Promise<void> {
|
||||
await this.http.invoke("delete_model_server", { serverId });
|
||||
}
|
||||
async deleteModelArtifact(serverId: string): Promise<void> {
|
||||
await this.http.invoke("delete_model_artifact", { serverId });
|
||||
}
|
||||
previewModelServerCommand(config: LocalModelServerConfig): Promise<ModelServerCommandPreview> {
|
||||
return this.http.invoke<ModelServerCommandPreview>("preview_model_server_command", { config });
|
||||
}
|
||||
|
||||
@ -1525,6 +1525,36 @@ export class MockModelServerGateway implements ModelServerGateway {
|
||||
this.servers = this.servers.filter((s) => s.id !== serverId);
|
||||
}
|
||||
|
||||
async deleteModelArtifact(serverId: string): Promise<void> {
|
||||
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<ModelServerCommandPreview> {
|
||||
|
||||
@ -32,6 +32,10 @@ export class TauriModelServerGateway implements ModelServerGateway {
|
||||
await invoke("delete_model_server", { serverId });
|
||||
}
|
||||
|
||||
async deleteModelArtifact(serverId: string): Promise<void> {
|
||||
await invoke("delete_model_artifact", { serverId });
|
||||
}
|
||||
|
||||
previewModelServerCommand(
|
||||
config: LocalModelServerConfig,
|
||||
): Promise<ModelServerCommandPreview> {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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" }),
|
||||
|
||||
@ -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<ModelArtifact, { state: "downloaded" }>,
|
||||
): 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<ModelArtifact, { state: "downloaded" }>,
|
||||
) {
|
||||
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
|
||||
await vm.deleteArtifact(server.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="local model servers"
|
||||
@ -98,6 +136,11 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
{vm.notice && (
|
||||
<p role="status" className="text-sm text-muted">
|
||||
{vm.notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.servers.length === 0 && !draft && (
|
||||
<p className="text-xs text-muted">
|
||||
@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
)}
|
||||
|
||||
<ul className="flex list-none flex-col gap-2 p-0">
|
||||
{vm.servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{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 (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{downloadingArtifact && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label={`download in progress ${server.name}`}
|
||||
disabled
|
||||
>
|
||||
Téléchargement en cours
|
||||
</Button>
|
||||
)}
|
||||
{downloaded && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label={`delete downloaded model ${server.name}`}
|
||||
onClick={() => void confirmDeleteArtifact(server, downloaded)}
|
||||
loading={deletingArtifact}
|
||||
disabled={vm.busy && !deletingArtifact}
|
||||
>
|
||||
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{draft && (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<void>;
|
||||
/** Creates or updates a server; returns the persisted config (or `null` on error). */
|
||||
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||
/** Deletes a server by id; returns `true` on success. */
|
||||
remove: (serverId: string) => Promise<boolean>;
|
||||
/** Deletes only the managed downloaded model artifact; returns `true` on success. */
|
||||
deleteArtifact: (serverId: string) => Promise<boolean>;
|
||||
/**
|
||||
* 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<LocalModelServerConfig[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deletingArtifactId, setDeletingArtifactId] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/cwd" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(terminalOptions[0]).toMatchObject({
|
||||
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||
});
|
||||
expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000);
|
||||
});
|
||||
});
|
||||
@ -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" }));
|
||||
|
||||
@ -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<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
data-testid="terminal-view"
|
||||
@ -450,6 +498,34 @@ export function TerminalView({
|
||||
visibility: terminalReady ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
{showNetworkBanner && (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="terminal-network-banner"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
padding: "0.5rem 0.75rem",
|
||||
border: "1px solid rgba(245, 158, 11, 0.45)",
|
||||
borderRadius: 6,
|
||||
background: "rgba(24, 24, 27, 0.94)",
|
||||
color: "var(--color-warning, #f59e0b)",
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{systemPermissions.runtimeLock.state === "locked"
|
||||
? "Réseau verrouillé par le runtime."
|
||||
: "Réseau interdit pour cet agent."}{" "}
|
||||
{networkReason}
|
||||
</div>
|
||||
)}
|
||||
{!terminalReady && !openError && (
|
||||
<div
|
||||
data-testid="terminal-boot-placeholder"
|
||||
|
||||
@ -744,7 +744,7 @@ export interface SaveOpenCodeProviderProfileInput {
|
||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||
* `OpenCodeConfig.localModelServerId`. Mirrors the backend commands
|
||||
* (`list_model_servers`, `save_model_server`, `delete_model_server`,
|
||||
* `preview_model_server_command`).
|
||||
* `preview_model_server_command`, `delete_model_artifact`).
|
||||
*/
|
||||
export interface ModelServerGateway {
|
||||
/** Lists the declared local model servers. */
|
||||
@ -759,6 +759,13 @@ export interface ModelServerGateway {
|
||||
* `model_server_in_use` when a profile still references it.
|
||||
*/
|
||||
deleteModelServer(serverId: string): Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* Builds the `llama-server` command line the backend would launch for the
|
||||
* draft config, without persisting it. The backend is the sole authority on
|
||||
|
||||
Reference in New Issue
Block a user