feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)

Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri).

#35 — Serveur de modèle local intégré (llama.cpp) :
- domain: model_server.rs (agrégat + statut), ports ModelServerProbe /
  ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements
  model_server_status_changed et agent_launch_failed.
- application: use case EnsureLocalModelServer branché sur LaunchAgent.
- infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime,
  LocalManagedProcess, FsModelServerRegistry.
- app-tauri: DTO plat LocalModelServerConfigDto, commandes
  list/save/delete_model_server avec garde model_server_in_use, wiring.

#36 — Profils OpenCode locaux multiples :
- domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id.
- application: use case CloneOpenCodeProfileFromSeed.
- app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring.

Tests verts (exécution réelle) : domain+application OK, app-tauri
dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2,
application model_server+profile_usecases 19/19, cargo build workspace Finished.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:50:08 +02:00
parent 2e98f1fbb9
commit b82ac76f8b
24 changed files with 3147 additions and 94 deletions

View File

@ -35,6 +35,7 @@ use domain::live_state::WorkStatus;
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput};
use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
@ -1136,6 +1137,10 @@ pub struct LaunchAgent {
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
/// parse ⇒ section omise, jamais d'échec de lancement.
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
/// Ensure local model servers referenced by OpenCode profiles before writing
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
/// when `OpenCodeConfig.localModelServerId` is set.
local_model_server: Option<Arc<EnsureLocalModelServer>>,
}
impl LaunchAgent {
@ -1174,9 +1179,17 @@ impl LaunchAgent {
provider_sessions: None,
projectors: None,
live_state_lean: None,
local_model_server: None,
}
}
/// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
self.local_model_server = Some(ensure);
self
}
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
/// `# État du projet` (status + intent des autres agents) est injecté dans le
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
@ -1550,7 +1563,7 @@ impl LaunchAgent {
.contexts
.read_context(&input.project, &agent.id)
.await?;
let profile = self
let mut profile = self
.profiles
.list()
.await?
@ -1649,6 +1662,9 @@ impl LaunchAgent {
)
.await?;
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
@ -2336,6 +2352,60 @@ impl LaunchAgent {
}
}
}
async fn ensure_local_model_server_for_opencode(
&self,
agent: &Agent,
profile: &mut AgentProfile,
) -> Result<(), AppError> {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(());
}
let Some(opencode) = profile.opencode.as_mut() else {
return Ok(());
};
let Some(server_id) = opencode.local_model_server_id else {
return Ok(());
};
let Some(ensure) = self.local_model_server.as_ref() else {
let err = AppError::ModelServer {
code: "not_wired".to_owned(),
message:
"OpenCode profile references localModelServerId but no model server service is wired"
.to_owned(),
};
self.publish_agent_launch_failed(agent.id, &err);
return Err(err);
};
match ensure
.execute(EnsureLocalModelServerInput { server_id })
.await
{
Ok(output) => {
opencode.base_url = output.ready.base_url;
opencode.model = output.ready.model;
Ok(())
}
Err(err) => {
self.publish_agent_launch_failed(agent.id, &err);
Err(err)
}
}
}
fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) {
let (cause, code) = match err {
AppError::ModelServer { code, .. } => ("model_server", code.as_str()),
_ => ("launch", err.code()),
};
self.events.publish(DomainEvent::AgentLaunchFailed {
agent_id,
cause: cause.to_owned(),
code: code.to_owned(),
message: err.to_string(),
});
}
}
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a

View File

@ -41,8 +41,10 @@ pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles,
ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveProfile, SaveProfileInput, SaveProfileOutput,
};

View File

@ -13,12 +13,13 @@
use std::sync::Arc;
use domain::ports::{AgentRuntime, ProfileStore};
use domain::profile::AgentProfile;
use domain::ids::ProfileId;
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
use crate::error::AppError;
use super::catalogue::selectable_reference_profiles;
use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
// ---------------------------------------------------------------------------
// DetectProfiles
@ -133,6 +134,111 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile,
}
// ---------------------------------------------------------------------------
// CloneOpenCodeProfileFromSeed
// ---------------------------------------------------------------------------
/// Input for [`CloneOpenCodeProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneOpenCodeProfileFromSeedInput {
/// Optional display name for the cloned profile. When absent, a copy label is
/// derived from the seed name.
pub name: Option<String>,
/// Optional OpenCode config override. When absent, the seed config is copied.
pub opencode: Option<OpenCodeConfig>,
}
/// Output of [`CloneOpenCodeProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneOpenCodeProfileFromSeedOutput {
/// The newly persisted profile.
pub profile: AgentProfile,
}
/// Creates a new OpenCode profile instance from the canonical seed/template.
///
/// The persisted canonical `opencode-llamacpp` profile is preferred when present,
/// so local edits are preserved as the clone template. If it is absent, the
/// in-memory reference catalogue seed is used. The new profile always receives a
/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles
/// with `StructuredAdapter::OpenCode` are therefore valid.
pub struct CloneOpenCodeProfileFromSeed {
store: Arc<dyn ProfileStore>,
ids: Arc<dyn IdGenerator>,
}
impl CloneOpenCodeProfileFromSeed {
/// 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 canonical OpenCode seed into a new persisted profile.
///
/// # Errors
/// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the
/// requested name is blank, or [`AppError::Internal`] if the seed is malformed.
pub async fn execute(
&self,
input: CloneOpenCodeProfileFromSeedInput,
) -> Result<CloneOpenCodeProfileFromSeedOutput, AppError> {
let existing = self.store.list().await?;
let seed_id = reference_profile_id("opencode-llamacpp");
let seed = existing
.iter()
.find(|profile| profile.id == seed_id)
.cloned()
.or_else(|| {
reference_profiles()
.into_iter()
.find(|profile| profile.id == seed_id)
})
.ok_or_else(|| {
AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into())
})?;
if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() {
return Err(AppError::Internal(
"canonical OpenCode seed is not an OpenCode profile".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(config) = input.opencode {
profile.opencode = Some(config);
}
self.store.save(&profile).await?;
Ok(CloneOpenCodeProfileFromSeedOutput { profile })
}
}
fn fresh_profile_id(
ids: &dyn IdGenerator,
existing: &[AgentProfile],
) -> Result<ProfileId, AppError> {
for _ in 0..16 {
let id = ProfileId::from_uuid(ids.new_uuid());
if existing.iter().all(|profile| profile.id != id) {
return Ok(id);
}
}
Err(AppError::Internal(
"could not allocate a unique profile id".into(),
))
}
/// Persists (creates or replaces) a single profile.
pub struct SaveProfile {
store: Arc<dyn ProfileStore>,

View File

@ -6,8 +6,8 @@
//! with one error shape when building its `ErrorDTO`.
use domain::ports::{
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
RemoteError, RuntimeError, StoreError,
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
};
use domain::{AgentId, NodeId};
use domain::{IssueStoreError, SprintStoreError};
@ -38,6 +38,15 @@ pub enum AppError {
#[error("process error: {0}")]
Process(String),
/// A local model server could not be prepared for launch.
#[error("model server error ({code}): {message}")]
ModelServer {
/// Stable model-server error code.
code: String,
/// Human-readable message.
message: String,
},
/// A git operation failed.
#[error("git error: {0}")]
Git(String),
@ -101,6 +110,7 @@ impl AppError {
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",
Self::Process(_) => "PROCESS",
Self::ModelServer { .. } => "MODEL_SERVER",
Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
@ -183,6 +193,30 @@ impl From<ProcessError> for AppError {
}
}
impl From<ModelServerError> for AppError {
fn from(e: ModelServerError) -> Self {
Self::ModelServer {
code: model_server_error_code(&e).to_owned(),
message: e.to_string(),
}
}
}
fn model_server_error_code(err: &ModelServerError) -> &'static str {
match err {
ModelServerError::NotConfigured => "not_configured",
ModelServerError::Invalid(_) => "invalid",
ModelServerError::PermissionDenied(_) => "permission_denied",
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
ModelServerError::PortOccupied(_) => "port_occupied",
ModelServerError::InUse(_) => "model_server_in_use",
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Timeout => "timeout",
}
}
impl From<RuntimeError> for AppError {
fn from(e: RuntimeError) -> Self {
Self::Process(e.to_string())

View File

@ -22,6 +22,7 @@ pub mod health;
pub mod issues;
pub mod layout;
pub mod memory;
pub mod model_server;
pub mod orchestrator;
pub mod permission;
pub mod project;
@ -39,19 +40,20 @@ pub use agent::{
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
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, ListProfiles,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
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, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
@ -102,6 +104,12 @@ pub use memory::{
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use model_server::{
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
SaveModelServerInput, SaveModelServerOutput,
};
pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,

View File

@ -0,0 +1,447 @@
//! Use cases for local model servers.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use domain::events::DomainEvent;
use domain::model_server::{
LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
};
use domain::{LocalModelServerId, StopPolicy};
use crate::error::AppError;
/// Output of [`ListModelServers::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelServersOutput {
/// Persisted local model-server configs.
pub servers: Vec<LocalModelServerConfig>,
}
/// Lists local model-server configurations.
pub struct ListModelServers {
registry: Arc<dyn ModelServerRegistry>,
}
impl ListModelServers {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
}
/// Lists configs.
///
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
Ok(ListModelServersOutput {
servers: self.registry.list().await?,
})
}
}
/// Input for [`SaveModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveModelServerInput {
/// Config to upsert by id.
pub config: LocalModelServerConfig,
}
/// Output of [`SaveModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveModelServerOutput {
/// Saved config.
pub config: LocalModelServerConfig,
}
/// Saves a local model-server configuration.
pub struct SaveModelServer {
registry: Arc<dyn ModelServerRegistry>,
}
impl SaveModelServer {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
}
/// Saves a config.
///
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(
&self,
input: SaveModelServerInput,
) -> Result<SaveModelServerOutput, AppError> {
self.registry.save(input.config.clone()).await?;
Ok(SaveModelServerOutput {
config: input.config,
})
}
}
/// Input for [`DeleteModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteModelServerInput {
/// Config id to delete.
pub server_id: LocalModelServerId,
}
/// Deletes a local model-server config when no OpenCode profile still references it.
pub struct DeleteModelServer {
registry: Arc<dyn ModelServerRegistry>,
profiles: Arc<dyn ProfileStore>,
}
impl DeleteModelServer {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>, profiles: Arc<dyn ProfileStore>) -> Self {
Self { registry, profiles }
}
/// Deletes a config after checking profile references.
///
/// # Errors
/// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced.
pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> {
let profiles = self.profiles.list().await?;
if profiles.iter().any(|profile| {
profile
.opencode
.as_ref()
.and_then(|config| config.local_model_server_id)
== Some(input.server_id)
}) {
return Err(ModelServerError::InUse(input.server_id.to_string()).into());
}
self.registry.delete(input.server_id).await?;
Ok(())
}
}
/// Input for [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerInput {
/// Referenced local model server.
pub server_id: LocalModelServerId,
}
/// Output of [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerOutput {
/// Ready server data to inject into OpenCode config.
pub ready: ModelServerReady,
}
/// Readiness retry policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadinessPolicy {
/// Number of probes after spawning.
pub attempts: usize,
/// Delay between attempts.
pub backoff: Duration,
}
impl Default for ReadinessPolicy {
fn default() -> Self {
Self {
attempts: 20,
backoff: Duration::from_millis(250),
}
}
}
#[derive(Debug, Clone)]
struct ActiveServer {
handle: ManagedProcessHandle,
port: u16,
stop_policy: StopPolicy,
}
/// Ensures a configured local model server is reachable, starting it when allowed.
pub struct EnsureLocalModelServer {
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
process: Arc<dyn ManagedProcess>,
runtime: Arc<dyn ModelServerRuntime>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
readiness: ReadinessPolicy,
}
impl EnsureLocalModelServer {
/// Builds the use case.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
process: Arc<dyn ManagedProcess>,
runtime: Arc<dyn ModelServerRuntime>,
fs: Arc<dyn FileSystem>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
registry,
probe,
process,
runtime,
fs,
events,
active: Mutex::new(HashMap::new()),
readiness: ReadinessPolicy::default(),
}
}
/// Overrides readiness policy, mainly for tests.
#[must_use]
pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self {
self.readiness = readiness;
self
}
/// Ensures the server is reachable.
///
/// # Errors
/// [`AppError::ModelServer`] if the server cannot be prepared.
pub async fn execute(
&self,
input: EnsureLocalModelServerInput,
) -> Result<EnsureLocalModelServerOutput, AppError> {
let config = self
.registry
.get(&input.server_id)
.await?
.ok_or(ModelServerError::NotConfigured)?;
self.publish(config.id, ModelServerLifecycleStatus::Probing);
let initial_probe = match self.probe.probe(&config.endpoint).await {
Ok(status) => status,
Err(err) => return self.fail(config.id, err),
};
match initial_probe {
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: true },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyReused),
});
}
ModelServerStatus::Unreachable => {}
}
if !config.auto_start {
let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned());
self.publish_failure(config.id, &err);
return Err(err.into());
}
self.ensure_model_path_accessible(&config).await?;
self.ensure_no_active_port_collision(&config).await?;
self.publish(config.id, ModelServerLifecycleStatus::Starting);
let spec = match self.runtime.build_spawn_spec(&config) {
Ok(spec) => spec,
Err(err) => return self.fail(config.id, err),
};
let handle = match self.process.spawn(spec).await {
Ok(handle) => handle,
Err(err) => return self.fail(config.id, err),
};
self.active.lock().unwrap().insert(
config.id,
ActiveServer {
handle: handle.clone(),
port: config.endpoint.port,
stop_policy: config.stop_policy,
},
);
for attempt in 0..self.readiness.attempts {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, &handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(&config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {
if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
}
}
}
}
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, &handle).await;
self.fail(config.id, err)
}
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
///
/// # Errors
/// Returns the first process error after attempting every eligible stop.
pub async fn stop_on_app_exit(&self) -> Result<(), AppError> {
let entries: Vec<(LocalModelServerId, ActiveServer)> = self
.active
.lock()
.unwrap()
.iter()
.map(|(id, active)| (*id, active.clone()))
.collect();
let mut first_error: Option<ModelServerError> = None;
for (id, active) in entries {
if active.stop_policy != StopPolicy::StopOnAppExit {
continue;
}
if let Err(err) = self.process.kill(&active.handle).await {
if first_error.is_none() {
first_error = Some(err);
}
} else {
self.active.lock().unwrap().remove(&id);
}
}
if let Some(err) = first_error {
Err(err.into())
} else {
Ok(())
}
}
async fn ensure_model_path_accessible(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
let Some(path) = config.model.path.as_ref() else {
let err = ModelServerError::PathNotAccessible("model.path missing".to_owned());
self.publish_failure(config.id, &err);
return Err(err.into());
};
match self
.fs
.exists(&RemotePath::new(path.as_str().to_owned()))
.await
{
Ok(true) => Ok(()),
Ok(false) => {
let err = ModelServerError::PathNotAccessible(path.as_str().to_owned());
self.publish_failure(config.id, &err);
Err(err.into())
}
Err(domain::ports::FsError::PermissionDenied(p)) => {
let err = ModelServerError::PermissionDenied(p);
self.publish_failure(config.id, &err);
Err(err.into())
}
Err(err) => {
let err = ModelServerError::PathNotAccessible(err.to_string());
self.publish_failure(config.id, &err);
Err(err.into())
}
}
}
async fn ensure_no_active_port_collision(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
let active: Vec<(LocalModelServerId, ActiveServer)> = self
.active
.lock()
.unwrap()
.iter()
.map(|(id, active)| (*id, active.clone()))
.collect();
for (id, active) in active {
if active.port != config.endpoint.port {
continue;
}
match self.process.status(&active.handle).await {
Ok(ProcessStatus::Running) => {
let err = ModelServerError::PortOccupied(config.endpoint.port);
self.publish_failure(config.id, &err);
return Err(err.into());
}
Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&id);
}
Err(err) => return self.fail(config.id, err),
}
}
Ok(())
}
async fn stop_started_server(
&self,
server_id: LocalModelServerId,
handle: &ManagedProcessHandle,
) {
let _ = self.process.kill(handle).await;
self.active.lock().unwrap().remove(&server_id);
}
fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) {
self.events
.publish(DomainEvent::ModelServerStatusChanged { server_id, status });
}
fn fail<T>(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result<T, AppError> {
self.publish_failure(server_id, &err);
Err(err.into())
}
fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) {
self.publish(
server_id,
ModelServerLifecycleStatus::Failed {
code: model_server_error_code(err).to_owned(),
message: err.to_string(),
},
);
}
}
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
ModelServerReady {
base_url: config.endpoint.base_url.clone(),
model: config.model.served_name.clone(),
status,
}
}
/// Stable model-server error code for event/DTO mapping.
#[must_use]
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
match err {
ModelServerError::NotConfigured => "not_configured",
ModelServerError::Invalid(_) => "invalid",
ModelServerError::PermissionDenied(_) => "permission_denied",
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
ModelServerError::PortOccupied(_) => "port_occupied",
ModelServerError::InUse(_) => "model_server_in_use",
ModelServerError::Probe(_) => "probe",
ModelServerError::Process(_) => "process",
ModelServerError::Store(_) => "store",
ModelServerError::Timeout => "timeout",
}
}

View File

@ -0,0 +1,512 @@
//! Unit tests for the local model-server ensure use case.
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use application::{
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
ModelServerReadinessPolicy,
};
use domain::events::DomainEvent;
use domain::model_server::{
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
ModelServerEndpoint, ModelServerStatus, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::{LocalModelServerId, ProfileId, ProjectPath};
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn config(
id: LocalModelServerId,
port: u16,
path: &str,
auto_start: bool,
) -> LocalModelServerConfig {
LocalModelServerConfig::new(
id,
LocalModelServerKind::LlamaCpp,
"llama.cpp",
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
LocalModelRef::new(
"qwen",
"Qwen",
Some(ModelPath::new(path).unwrap()),
"qwen3-coder-30b",
)
.unwrap(),
Some(ExecutablePath::new("llama-server").unwrap()),
Vec::new(),
auto_start,
StopPolicy::StopOnAppExit,
)
.unwrap()
}
#[derive(Default)]
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
#[async_trait]
impl ModelServerRegistry for FakeRegistry {
async fn get(
&self,
id: &LocalModelServerId,
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
Ok(self.0.lock().unwrap().get(id).cloned())
}
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
Ok(self.0.lock().unwrap().values().cloned().collect())
}
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
self.0.lock().unwrap().insert(config.id, config);
Ok(())
}
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
self.0.lock().unwrap().remove(&id);
Ok(())
}
}
#[derive(Default)]
struct FakeProfiles(Mutex<Vec<AgentProfile>>);
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
self.0.lock().unwrap().push(profile.clone());
Ok(())
}
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
fn opencode_profile(id: u128, server_id: LocalModelServerId) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
"Local OpenCode",
"opencode",
Vec::new(),
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen", None, None)
.unwrap()
.with_local_model_server_id(server_id),
)
}
struct FakeProbe(Mutex<VecDeque<ModelServerStatus>>);
impl FakeProbe {
fn new(statuses: Vec<ModelServerStatus>) -> Self {
Self(Mutex::new(statuses.into()))
}
}
#[async_trait]
impl ModelServerProbe for FakeProbe {
async fn probe(
&self,
_endpoint: &ModelServerEndpoint,
) -> Result<ModelServerStatus, ModelServerError> {
Ok(self
.0
.lock()
.unwrap()
.pop_front()
.unwrap_or(ModelServerStatus::Unreachable))
}
}
#[derive(Default)]
struct FakeProcess {
spawns: Mutex<Vec<SpawnSpec>>,
kills: Mutex<Vec<String>>,
statuses: Mutex<HashMap<String, ProcessStatus>>,
}
#[async_trait]
impl ManagedProcess for FakeProcess {
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
self.spawns.lock().unwrap().push(spec);
let handle = ManagedProcessHandle {
id: format!("h{}", self.spawns.lock().unwrap().len()),
};
self.statuses
.lock()
.unwrap()
.insert(handle.id.clone(), ProcessStatus::Running);
Ok(handle)
}
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
self.kills.lock().unwrap().push(handle.id.clone());
Ok(())
}
async fn status(
&self,
handle: &ManagedProcessHandle,
) -> Result<ProcessStatus, ModelServerError> {
Ok(*self
.statuses
.lock()
.unwrap()
.get(&handle.id)
.unwrap_or(&ProcessStatus::Unknown))
}
}
struct FakeRuntime;
impl ModelServerRuntime for FakeRuntime {
fn build_spawn_spec(
&self,
config: &LocalModelServerConfig,
) -> Result<SpawnSpec, ModelServerError> {
Ok(SpawnSpec {
command: config
.binary
.as_ref()
.map(|binary| binary.as_str().to_owned())
.unwrap_or_else(|| "llama-server".to_owned()),
args: vec![
"--model".to_owned(),
config.model.path.as_ref().unwrap().as_str().to_owned(),
"--port".to_owned(),
config.endpoint.port.to_string(),
],
cwd: ProjectPath::new("/").unwrap(),
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
}
#[derive(Default)]
struct FakeFs {
existing: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, _path: &RemotePath) -> Result<Vec<u8>, FsError> {
Err(FsError::NotFound("unused".to_owned()))
}
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self
.existing
.lock()
.unwrap()
.iter()
.any(|p| p == path.as_str()))
}
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
#[derive(Default)]
struct FakeEvents(Mutex<Vec<DomainEvent>>);
impl EventBus for FakeEvents {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
fn ensure(
registry: Arc<FakeRegistry>,
probe: Arc<FakeProbe>,
process: Arc<FakeProcess>,
fs: Arc<FakeFs>,
events: Arc<FakeEvents>,
) -> EnsureLocalModelServer {
EnsureLocalModelServer::new(registry, probe, process, Arc::new(FakeRuntime), fs, events)
.with_readiness_policy(ModelServerReadinessPolicy {
attempts: 2,
backoff: Duration::ZERO,
})
}
#[tokio::test]
async fn reachable_server_is_reused_without_spawn() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(1), 8080, "/models/qwen.gguf", true))
.await
.unwrap();
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
Arc::clone(&process),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(1) })
.await
.unwrap();
assert_eq!(out.ready.base_url, "http://localhost:8080/v1");
assert_eq!(out.ready.model, "qwen3-coder-30b");
assert!(process.spawns.lock().unwrap().is_empty());
}
#[tokio::test]
async fn absent_auto_start_spawns_and_waits_until_ready() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(2), 8081, "/models/qwen.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.push("/models/qwen.gguf".to_owned());
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyReused,
])),
Arc::clone(&process),
fs,
Arc::new(FakeEvents::default()),
);
let out = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(2) })
.await
.unwrap();
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
let spawns = process.spawns.lock().unwrap();
assert_eq!(spawns.len(), 1);
assert_eq!(spawns[0].command, "llama-server");
assert_eq!(
spawns[0].args,
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
);
}
#[tokio::test]
async fn missing_model_path_is_path_not_accessible() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(3), 8082, "/models/missing.gguf", true))
.await
.unwrap();
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(3) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
assert!(err.to_string().contains("path_not_accessible"));
}
#[tokio::test]
async fn active_managed_port_collision_is_explicit_error() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(4), 8083, "/models/a.gguf", true))
.await
.unwrap();
registry
.save(config(sid(5), 8083, "/models/b.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.extend(["/models/a.gguf".to_owned(), "/models/b.gguf".to_owned()]);
let process = Arc::new(FakeProcess::default());
let events = Arc::new(FakeEvents::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::ReadyReused,
ModelServerStatus::Unreachable,
])),
Arc::clone(&process),
fs,
events,
);
usecase
.execute(EnsureLocalModelServerInput { server_id: sid(4) })
.await
.unwrap();
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(5) })
.await
.unwrap_err();
assert!(err.to_string().contains("port_occupied"));
assert_eq!(process.spawns.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn readiness_timeout_kills_started_process() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(6), 8084, "/models/qwen.gguf", true))
.await
.unwrap();
let fs = Arc::new(FakeFs::default());
fs.existing
.lock()
.unwrap()
.push("/models/qwen.gguf".to_owned());
let process = Arc::new(FakeProcess::default());
let usecase = ensure(
Arc::clone(&registry),
Arc::new(FakeProbe::new(vec![
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
ModelServerStatus::Unreachable,
])),
Arc::clone(&process),
fs,
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(6) })
.await
.unwrap_err();
assert!(err.to_string().contains("timeout"));
assert_eq!(process.spawns.lock().unwrap().len(), 1);
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
}
#[tokio::test]
async fn missing_registry_entry_is_model_server_not_configured() {
let usecase = ensure(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
let err = usecase
.execute(EnsureLocalModelServerInput { server_id: sid(7) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
assert!(err.to_string().contains("not_configured"));
}
#[tokio::test]
async fn delete_model_server_refuses_when_profile_references_it() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(8), 8085, "/models/qwen.gguf", false))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(18, sid(8))])));
let usecase = DeleteModelServer::new(registry, profiles);
let err = usecase
.execute(DeleteModelServerInput { server_id: sid(8) })
.await
.unwrap_err();
assert_eq!(err.code(), "MODEL_SERVER");
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
}
#[tokio::test]
async fn delete_model_server_removes_unused_config() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(9), 8086, "/models/qwen.gguf", false))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles::default());
let usecase = DeleteModelServer::new(
Arc::clone(&registry) as Arc<dyn ModelServerRegistry>,
profiles,
);
usecase
.execute(DeleteModelServerInput { server_id: sid(9) })
.await
.unwrap();
assert!(registry.get(&sid(9)).await.unwrap().is_none());
}

View File

@ -13,15 +13,17 @@ use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime {
}
}
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
impl SeqIds {
fn new(ids: Vec<uuid::Uuid>) -> Self {
Self(Mutex::new(ids))
}
}
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> uuid::Uuid {
self.0.lock().unwrap().remove(0)
}
}
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
let store = FakeProfileStore::default();
let clone = CloneOpenCodeProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
);
let out = clone
.execute(CloneOpenCodeProfileFromSeedInput {
name: Some("OpenCode llama.cpp 2".to_owned()),
opencode: Some(
OpenCodeConfig::new(
"http://localhost:8081/v1",
None,
"qwen3-coder-70b",
Some(false),
Some(true),
)
.unwrap(),
),
})
.await
.unwrap();
let seed_id = reference_profile_id("opencode-llamacpp");
assert_ne!(out.profile.id, seed_id);
assert_eq!(
out.profile.id,
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
);
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::OpenCode)
);
assert_eq!(
out.profile
.opencode
.as_ref()
.map(|config| config.base_url.as_str()),
Some("http://localhost:8081/v1")
);
let profiles = store.0.lock().unwrap().profiles.clone();
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].id, out.profile.id);
}
#[tokio::test]
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
let store = FakeProfileStore::default();
let save = SaveProfile::new(Arc::new(store.clone()));
let mut seed = reference_profiles()
.into_iter()
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
.expect("seed exists");
seed.name = "Edited local OpenCode seed".to_owned();
save.execute(SaveProfileInput {
profile: seed.clone(),
})
.await
.unwrap();
let clone = CloneOpenCodeProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
);
let out = clone
.execute(CloneOpenCodeProfileFromSeedInput {
name: None,
opencode: None,
})
.await
.unwrap();
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
assert_eq!(out.profile.opencode, seed.opencode);
let profiles = store.0.lock().unwrap().profiles.clone();
assert_eq!(
profiles
.iter()
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
.count(),
1,
"the canonical seed must be preserved, not recreated as a duplicate"
);
assert_eq!(
profiles
.iter()
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
.count(),
2,
"ProfileId is the identity: multiple OpenCode profiles can coexist"
);
}
// ---------------------------------------------------------------------------
// ReferenceProfiles / catalogue
// ---------------------------------------------------------------------------