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:
@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::input::AgentLiveness;
|
||||
use domain::model_server::ModelServerLifecycleStatus;
|
||||
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
pub const DOMAIN_EVENT: &str = "domain://event";
|
||||
/// Dedicated Tauri event for local model-server status changes.
|
||||
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
|
||||
/// Dedicated Tauri event for launch failures.
|
||||
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";
|
||||
|
||||
/// Model-server lifecycle status on the Tauri wire.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum ModelServerStatusDto {
|
||||
/// No server is configured for this profile.
|
||||
NotConfigured,
|
||||
/// Readiness probing is in progress.
|
||||
Probing,
|
||||
/// IdeA is starting the process.
|
||||
Starting,
|
||||
/// Server is ready.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ready {
|
||||
/// `true` when an existing server was reused.
|
||||
reused: bool,
|
||||
},
|
||||
/// Server preparation failed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Failed {
|
||||
/// Stable failure code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
|
||||
fn from(status: &ModelServerLifecycleStatus) -> Self {
|
||||
match status {
|
||||
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
|
||||
ModelServerLifecycleStatus::Probing => Self::Probing,
|
||||
ModelServerLifecycleStatus::Starting => Self::Starting,
|
||||
ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
|
||||
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelServerStatusChangedDto {
|
||||
/// Local model server id.
|
||||
pub server_id: String,
|
||||
/// Lifecycle status.
|
||||
pub status: ModelServerStatusDto,
|
||||
}
|
||||
|
||||
/// Payload for [`AGENT_LAUNCH_FAILED`].
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentLaunchFailedDto {
|
||||
/// Agent id.
|
||||
pub agent_id: String,
|
||||
/// Failure cause namespace.
|
||||
pub cause: String,
|
||||
/// Stable error code.
|
||||
pub code: String,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
|
||||
///
|
||||
@ -42,6 +112,26 @@ pub enum DomainEventDto {
|
||||
/// Session id.
|
||||
session_id: String,
|
||||
},
|
||||
/// An agent launch failed before runtime session creation.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLaunchFailed {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// Failure cause namespace.
|
||||
cause: String,
|
||||
/// Stable error code.
|
||||
code: String,
|
||||
/// Human-readable message.
|
||||
message: String,
|
||||
},
|
||||
/// Local model-server status changed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ModelServerStatusChanged {
|
||||
/// Local model server id.
|
||||
server_id: String,
|
||||
/// Lifecycle status.
|
||||
status: ModelServerStatusDto,
|
||||
},
|
||||
/// An agent exited.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentExited {
|
||||
@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause,
|
||||
code,
|
||||
message,
|
||||
} => Self::AgentLaunchFailed {
|
||||
agent_id: agent_id.to_string(),
|
||||
cause: cause.clone(),
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||
Self::ModelServerStatusChanged {
|
||||
server_id: server_id.to_string(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
}
|
||||
let dto = DomainEventDto::from(&event);
|
||||
let _ = app.emit(DOMAIN_EVENT, dto);
|
||||
match &event {
|
||||
DomainEvent::ModelServerStatusChanged { server_id, status } => {
|
||||
let _ = app.emit(
|
||||
MODEL_SERVER_STATUS_CHANGED,
|
||||
ModelServerStatusChangedDto {
|
||||
server_id: server_id.to_string(),
|
||||
status: status.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
DomainEvent::AgentLaunchFailed {
|
||||
agent_id,
|
||||
cause,
|
||||
code,
|
||||
message,
|
||||
} => {
|
||||
let _ = app.emit(
|
||||
AGENT_LAUNCH_FAILED,
|
||||
AgentLaunchFailedDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
cause: cause.clone(),
|
||||
code: code.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The bus dropped some events for this slow receiver; keep going.
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
@ -1008,13 +1143,47 @@ mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::ProjectId;
|
||||
use domain::{LocalModelServerId, ProjectId};
|
||||
use serde_json::json;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn server(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
||||
server_id: server(35),
|
||||
status: ModelServerLifecycleStatus::Ready { reused: true },
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "modelServerStatusChanged");
|
||||
assert_eq!(json["serverId"], server(35).to_string());
|
||||
assert_eq!(json["status"]["state"], "ready");
|
||||
assert_eq!(json["status"]["reused"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_launch_failed_relays_model_server_cause() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
|
||||
agent_id: agent(36),
|
||||
cause: "model_server".to_owned(),
|
||||
code: "path_not_accessible".to_owned(),
|
||||
message: "path not accessible: /models/missing.gguf".to_owned(),
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLaunchFailed");
|
||||
assert_eq!(json["agentId"], agent(36).to_string());
|
||||
assert_eq!(json["cause"], "model_server");
|
||||
assert_eq!(json["code"], "path_not_accessible");
|
||||
}
|
||||
|
||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||
|
||||
Reference in New Issue
Block a user