merge feature/multi-profiles-codex-clarification-toast-notices dans develop

Résout le conflit d'imports/helpers de tests dans
crates/application/tests/model_server.rs par union des deux côtés
(imports application/domain + helpers aid/nid/sess).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 13:06:35 +02:00
63 changed files with 5674 additions and 993 deletions

View File

@ -9,6 +9,7 @@
mod catalogue;
mod inspect;
mod lifecycle;
mod model_catalogue;
mod provider_catalogue;
mod resume;
mod session_limit;
@ -39,6 +40,10 @@ pub use lifecycle::{
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry,
};
pub use provider_catalogue::{
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
@ -48,10 +53,11 @@ pub use resume::{
};
pub use usecases::{
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput,
};

View File

@ -0,0 +1,468 @@
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
//!
//! The CLIs do not expose a stable machine-readable model catalogue and must not
//! be asked to enumerate models. The only local probe allowed here is
//! `<cli> --version`; provider APIs are optional and failures degrade to the
//! static seed plus warnings.
use std::collections::BTreeSet;
use std::sync::Arc;
use domain::model_catalogue::{evaluate_compatibility, CliVersion};
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
use domain::profile::StructuredAdapter;
use domain::{ModelCatalogSource, ModelCompatibility};
/// One searchable model entry for a structured profile adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileModelCatalogEntry {
/// Structured adapter this model belongs to.
pub adapter: StructuredAdapter,
/// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
pub model_id: String,
/// Human-readable label for picker display.
pub display_name: String,
/// Extra search tokens useful to the frontend.
pub aliases: Vec<String>,
/// Whether this entry is the conservative default suggestion.
pub recommended: bool,
/// Compatibility state against the locally detected CLI version.
pub compatibility: ModelCompatibility,
/// Source that contributed the model entry.
pub source: ModelCatalogSource,
}
fn entry(
adapter: StructuredAdapter,
model_id: &str,
display_name: &str,
aliases: &[&str],
recommended: bool,
) -> ProfileModelCatalogEntry {
ProfileModelCatalogEntry {
adapter,
model_id: model_id.to_owned(),
display_name: display_name.to_owned(),
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
recommended,
compatibility: ModelCompatibility::Unknown,
source: ModelCatalogSource::Catalogue,
}
}
fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
ProfileModelCatalogEntry {
adapter,
display_name: model_id.clone(),
model_id,
aliases: Vec::new(),
recommended: false,
compatibility: ModelCompatibility::Unknown,
source: ModelCatalogSource::Provider,
}
}
/// Static Claude Code model catalogue.
#[must_use]
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Claude,
"claude-sonnet-5",
"Claude Sonnet 5",
&["sonnet"],
true,
),
entry(
StructuredAdapter::Claude,
"claude-opus-4-8",
"Claude Opus 4.8",
&["opus"],
false,
),
entry(
StructuredAdapter::Claude,
"claude-haiku-4-5-20251001",
"Claude Haiku 4.5",
&["haiku"],
false,
),
]
}
/// Static OpenAI Codex CLI model catalogue.
#[must_use]
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Codex,
"gpt-5-codex",
"GPT-5 Codex",
&["codex"],
true,
),
entry(
StructuredAdapter::Codex,
"gpt-5",
"GPT-5",
&["general"],
false,
),
entry(
StructuredAdapter::Codex,
"gpt-5-mini",
"GPT-5 mini",
&["mini", "fast"],
false,
),
]
}
/// Output of structured model-catalogue resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
/// Output of [`ListClaudeModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListClaudeModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
impl From<ListModelsOutput> for ListClaudeModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// Output of [`ListCodexModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListCodexModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
impl From<ListModelsOutput> for ListCodexModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// Use case resolving an enriched structured model catalogue.
pub struct ResolveModelCatalogue {
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
}
impl ResolveModelCatalogue {
/// Builds the use case from hexagonal ports.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
cli_versions,
provider_catalogue,
matrix_source,
}
}
/// Resolves models for one structured adapter. Infallible by design.
pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
let mut warnings = Vec::new();
let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
warnings.extend(matrix_warnings);
let cli_version = match self.cli_versions.read_cli_version(adapter).await {
Ok(version) => version,
Err(warning) => {
warnings.push(warning);
None
}
};
let mut models = match adapter {
StructuredAdapter::Claude => claude_model_catalogue(),
StructuredAdapter::Codex => codex_model_catalogue(),
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
};
match self.provider_catalogue.list_provider_models(adapter).await {
Ok(provider_models) => {
let existing = models
.iter()
.map(|model| model.model_id.clone())
.collect::<BTreeSet<_>>();
models.extend(
provider_models
.into_iter()
.filter(|model_id| !existing.contains(model_id))
.map(|model_id| provider_entry(adapter, model_id)),
);
}
Err(warning) => warnings.push(warning),
}
for model in &mut models {
model.compatibility =
evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
}
models.sort_by(|a, b| {
b.recommended
.cmp(&a.recommended)
.then_with(|| a.display_name.cmp(&b.display_name))
.then_with(|| a.model_id.cmp(&b.model_id))
});
ListModelsOutput {
models,
cli_version,
warnings,
}
}
}
/// Use case exposing the Claude model catalogue.
pub struct ListClaudeModels {
resolver: ResolveModelCatalogue,
}
impl ListClaudeModels {
/// Builds the use case.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
}
}
/// Lists Claude models. Infallible.
pub async fn execute(&self) -> ListClaudeModelsOutput {
self.resolver
.execute(StructuredAdapter::Claude)
.await
.into()
}
}
/// Use case exposing the Codex model catalogue.
pub struct ListCodexModels {
resolver: ResolveModelCatalogue,
}
impl ListCodexModels {
/// Builds the use case.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
}
}
/// Lists Codex models. Infallible.
pub async fn execute(&self) -> ListCodexModelsOutput {
self.resolver.execute(StructuredAdapter::Codex).await.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::{CliVersion, CompatibilityMatrix};
use std::collections::HashMap;
struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);
#[async_trait]
impl CliVersionReader for FakeCliVersionReader {
async fn read_cli_version(
&self,
_adapter: StructuredAdapter,
) -> Result<Option<CliVersion>, String> {
self.0
.clone()
.unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
}
}
struct FakeProvider(Vec<String>, Option<String>);
#[async_trait]
impl ProviderModelCatalogue for FakeProvider {
async fn list_provider_models(
&self,
_adapter: StructuredAdapter,
) -> Result<Vec<String>, String> {
if let Some(warning) = &self.1 {
Err(warning.clone())
} else {
Ok(self.0.clone())
}
}
}
struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);
impl CompatibilityMatrixSource for FakeMatrixSource {
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
(self.0.clone(), self.1.clone())
}
}
fn resolver(
version: Option<Result<Option<CliVersion>, String>>,
provider: Vec<String>,
provider_warning: Option<String>,
) -> ResolveModelCatalogue {
ResolveModelCatalogue::new(
Arc::new(FakeCliVersionReader(version)),
Arc::new(FakeProvider(provider, provider_warning)),
Arc::new(FakeMatrixSource(
CompatibilityMatrix {
version: 1,
claude: HashMap::from([
("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
]),
codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
},
vec![],
)),
)
}
#[test]
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
for (adapter, models) in [
(StructuredAdapter::Claude, claude_model_catalogue()),
(StructuredAdapter::Codex, codex_model_catalogue()),
] {
assert!(!models.is_empty());
assert_eq!(
models.iter().filter(|model| model.recommended).count(),
1,
"{adapter:?} should expose one default suggestion"
);
for model in models {
assert_eq!(model.adapter, adapter);
assert!(!model.model_id.trim().is_empty());
assert!(!model.display_name.trim().is_empty());
assert_eq!(model.compatibility, ModelCompatibility::Unknown);
assert_eq!(model.source, ModelCatalogSource::Catalogue);
}
}
}
#[tokio::test]
async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
let out = resolver(
Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
vec![],
None,
)
.execute(StructuredAdapter::Claude)
.await;
let sonnet = out
.models
.iter()
.find(|model| model.model_id == "claude-sonnet-5")
.unwrap();
let opus = out
.models
.iter()
.find(|model| model.model_id == "claude-opus-4-8")
.unwrap();
assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
assert!(out.warnings.is_empty());
}
#[tokio::test]
async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
let out = resolver(
Some(Err("codex version unavailable".to_owned())),
vec![],
Some("provider unavailable".to_owned()),
)
.execute(StructuredAdapter::Codex)
.await;
assert!(!out.models.is_empty());
assert_eq!(out.cli_version, None);
assert_eq!(
out.warnings,
vec![
"codex version unavailable".to_owned(),
"provider unavailable".to_owned()
]
);
assert!(out
.models
.iter()
.all(|model| model.compatibility == ModelCompatibility::Unknown));
}
#[tokio::test]
async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
let out = resolver(
None,
vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
None,
)
.execute(StructuredAdapter::Codex)
.await;
assert_eq!(
out.models
.iter()
.filter(|model| model.model_id == "gpt-5-codex")
.count(),
1
);
let provider = out
.models
.iter()
.find(|model| model.model_id == "gpt-5-provider")
.unwrap();
assert_eq!(provider.source, ModelCatalogSource::Provider);
assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
}
}

View File

@ -146,6 +146,92 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile,
}
// ---------------------------------------------------------------------------
// CloneProfileFromSeed
// ---------------------------------------------------------------------------
/// Input for [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedInput {
/// Id of the persisted or reference profile to clone.
pub seed_profile_id: ProfileId,
/// Optional display name for the cloned profile. When absent, a copy label is
/// derived from the seed name.
pub name: Option<String>,
/// Optional model override. When absent, the seed model is copied as-is.
pub model: Option<String>,
}
/// Output of [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedOutput {
/// The newly persisted profile.
pub profile: AgentProfile,
}
/// Creates a new profile instance from an existing persisted/reference seed.
///
/// Persisted profiles are preferred over reference seeds so user edits to the
/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the
/// backend [`IdGenerator`]; callers can override the display name and model
/// without minting ids client-side.
pub struct CloneProfileFromSeed {
store: Arc<dyn ProfileStore>,
ids: Arc<dyn IdGenerator>,
}
impl CloneProfileFromSeed {
/// Builds the use case from the profile store and id generator ports.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { store, ids }
}
/// Clones the requested seed into a new persisted profile.
///
/// # Errors
/// [`AppError::NotFound`] if no persisted/reference profile has the seed id,
/// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`]
/// on persistence failure.
pub async fn execute(
&self,
input: CloneProfileFromSeedInput,
) -> Result<CloneProfileFromSeedOutput, AppError> {
let existing = self.store.list().await?;
let seed = existing
.iter()
.find(|profile| profile.id == input.seed_profile_id)
.cloned()
.or_else(|| {
reference_profiles()
.into_iter()
.find(|profile| profile.id == input.seed_profile_id)
})
.ok_or(AppError::NotFound("profile seed not found".into()))?;
let mut profile = seed;
profile.id = fresh_profile_id(&*self.ids, &existing)?;
profile.name = match input.name {
Some(name) => {
if name.trim().is_empty() {
return Err(AppError::Invalid("profile.name must not be empty".into()));
}
name
}
None => format!("{} copy", profile.name),
};
if let Some(model) = input.model {
if model.trim().is_empty() {
return Err(AppError::Invalid("profile.model must not be empty".into()));
}
profile.model = Some(model);
}
self.store.save(&profile).await?;
Ok(CloneProfileFromSeedOutput { profile })
}
}
// ---------------------------------------------------------------------------
// CloneOpenCodeProfileFromSeed
// ---------------------------------------------------------------------------

View File

@ -40,21 +40,23 @@ pub mod window;
pub mod workstate;
pub use agent::{
drain_reply_stream_with_readiness, drain_with_readiness,
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness,
drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome,
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput,
ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels,
ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
@ -125,10 +127,12 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use model_server::{
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
SaveModelServerInput, SaveModelServerOutput,
model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer,
DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput,
ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem,
ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput,
SaveModelServerOutput,
};
pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,

View File

@ -11,33 +11,101 @@ use domain::model_server::{
ModelSource,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, ProjectStore, RemotePath,
};
use domain::{LocalModelServerId, ProjectId, 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 {
@ -738,6 +988,15 @@ impl EnsureLocalModelServer {
}
}
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
self.download_cancels
.lock()
.unwrap()
.contains_key(&server_id)
}
}
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
ModelServerReady {
base_url: config.endpoint.base_url.clone(),

View File

@ -127,6 +127,22 @@ fn resolve_background_cwd(
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
}
fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
match &task.kind {
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
conversation_id,
..
} => Some(domain::RendezvousContext {
requester_agent_id: *requester_agent_id,
target_agent_id: *target_agent_id,
conversation_id: *conversation_id,
}),
_ => None,
}
}
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
let mut out = PathBuf::new();
for component in path.components() {
@ -847,22 +863,26 @@ impl OrchestratorService {
if task.is_terminal() {
return Ok(());
}
let rendezvous = rendezvous_context_for_task(&task);
let event = match &result {
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
DomainEvent::BackgroundTaskCancelled {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
}
}
};
@ -2967,6 +2987,52 @@ mod tests {
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
}
#[test]
fn rendezvous_context_is_extracted_from_headless_background_task_kind() {
let requester = aid(1);
let target = aid(2);
let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3));
let task = BackgroundTask::new(
TaskId::from_uuid(uuid::Uuid::from_u128(4)),
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)),
target,
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id: Some(requester),
target_agent_id: target,
ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)),
conversation_id,
},
BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
let context = rendezvous_context_for_task(&task).expect("rendezvous context");
assert_eq!(context.requester_agent_id, Some(requester));
assert_eq!(context.target_agent_id, target);
assert_eq!(context.conversation_id, conversation_id);
}
#[test]
fn rendezvous_context_is_absent_for_command_background_task() {
let task = BackgroundTask::new(
TaskId::from_uuid(uuid::Uuid::from_u128(7)),
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)),
aid(9),
BackgroundTaskKind::Command {
label: "cargo test".to_owned(),
},
BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
assert_eq!(rendezvous_context_for_task(&task), None);
}
#[test]
fn explicit_profile_submit_delay_is_preserved() {
let p = profile(3, "OpenAI Codex CLI", "codex")

View File

@ -67,6 +67,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.
@ -92,6 +97,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 {
@ -444,6 +469,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 {
@ -853,42 +895,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
}
}
@ -902,4 +910,8 @@ impl LiveAgentRegistry for LiveSessions {
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
}
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
LiveSessions::live_agent_snapshots(self)
}
}

View File

@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState {
pub stdout_tail: Option<String>,
/// Bounded stderr tail.
pub stderr_tail: Option<String>,
/// Agent that requested a headless rendezvous, when this task is one.
pub requester_agent_id: Option<AgentId>,
/// Target agent for a headless rendezvous, when this task is one.
pub target_agent_id: Option<AgentId>,
/// Conversation opened by a headless rendezvous, when this task is one.
pub conversation_id: Option<domain::ConversationId>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
@ -592,6 +598,8 @@ impl GetProjectWorkState {
impl From<BackgroundTask> for AgentBackgroundTaskState {
fn from(task: BackgroundTask) -> Self {
let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task);
let (requester_agent_id, target_agent_id, conversation_id) =
flatten_background_rendezvous_context(&task.kind);
Self {
task_id: task.id,
kind: BackgroundTaskKindLabel::from(&task.kind),
@ -600,12 +608,37 @@ impl From<BackgroundTask> for AgentBackgroundTaskState {
summary,
stdout_tail,
stderr_tail,
requester_agent_id,
target_agent_id,
conversation_id,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
fn flatten_background_rendezvous_context(
kind: &BackgroundTaskKind,
) -> (
Option<AgentId>,
Option<AgentId>,
Option<domain::ConversationId>,
) {
match kind {
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
conversation_id,
..
} => (
*requester_agent_id,
Some(*target_agent_id),
Some(*conversation_id),
),
_ => (None, None, None),
}
}
impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel {
fn from(kind: &BackgroundTaskKind) -> Self {
match kind {