- #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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user