From 955db79e9787d225064cf26b02fea16ec8f55731 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 09:50:17 +0200 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20extraire=20le=20c=C5=93ur?= =?UTF-8?q?=20backend=20commun=20hors=20Tauri=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot B1 du chantier server/client mode : création de la crate `backend` qui héberge le cœur commun (endpoint MCP, outils OpenAI) indépendant de Tauri, préalable au futur serveur web + PTY WebSocket. - crates/backend : nouvelle crate (lib.rs, mcp_endpoint.rs, openai_tools.rs). - crates/app-tauri : câblage sur la crate backend (state.rs, mcp_bridge.rs, Cargo.toml, tests/orchestrator_wiring.rs). - Cargo.toml / Cargo.lock racine : ajout de la crate au workspace. Validé : cargo check --workspace vert, tests backend/app-tauri verts. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 16 + Cargo.toml | 2 + crates/app-tauri/Cargo.toml | 5 +- crates/app-tauri/src/mcp_bridge.rs | 1 + crates/app-tauri/src/state.rs | 6350 +--------------- crates/app-tauri/tests/orchestrator_wiring.rs | 4 + crates/backend/Cargo.toml | 22 + crates/backend/src/lib.rs | 6399 +++++++++++++++++ crates/backend/src/mcp_endpoint.rs | 317 + crates/backend/src/openai_tools.rs | 149 + 10 files changed, 6950 insertions(+), 6315 deletions(-) create mode 100644 crates/backend/Cargo.toml create mode 100644 crates/backend/src/lib.rs create mode 100644 crates/backend/src/mcp_endpoint.rs create mode 100644 crates/backend/src/openai_tools.rs diff --git a/Cargo.lock b/Cargo.lock index 4b6eccc..d99a624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,7 @@ version = "0.3.0" dependencies = [ "application", "async-trait", + "backend", "base64 0.22.1", "domain", "infrastructure", @@ -146,6 +147,21 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "backend" +version = "0.3.0" +dependencies = [ + "application", + "async-trait", + "domain", + "infrastructure", + "interprocess", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "base64" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index 5af4c30..ff5bd0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/domain", "crates/application", "crates/infrastructure", + "crates/backend", "crates/app-tauri", ] @@ -29,6 +30,7 @@ git2 = { version = "0.20", default-features = false } domain = { path = "crates/domain" } application = { path = "crates/application" } infrastructure = { path = "crates/infrastructure" } +backend = { path = "crates/backend" } # Tauri v2 tauri = { version = "2", features = [] } diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index c2ba971..adbe0f6 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-build = { workspace = true } domain = { workspace = true } application = { workspace = true } infrastructure = { workspace = true } +backend = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } # `io-std` (on top of the workspace features) gives the headless `mcp-server` @@ -44,8 +45,8 @@ interprocess = { version = "2.4", features = ["tokio"] } [features] # Passthrough toggles to enable the real embedders in an IDE build. OFF by default # (founding posture: `none` ⇒ naïve recall, zero dependency). -vector-http = ["infrastructure/vector-http"] -vector-onnx = ["infrastructure/vector-onnx"] +vector-http = ["infrastructure/vector-http", "backend/vector-http"] +vector-onnx = ["infrastructure/vector-onnx", "backend/vector-onnx"] [dev-dependencies] uuid = { workspace = true } diff --git a/crates/app-tauri/src/mcp_bridge.rs b/crates/app-tauri/src/mcp_bridge.rs index 4304098..7d44c25 100644 --- a/crates/app-tauri/src/mcp_bridge.rs +++ b/crates/app-tauri/src/mcp_bridge.rs @@ -600,6 +600,7 @@ mod tests { /// the server (reads the handshake, echoes a canned response); the relay drives /// it over an actual connection. Verifies the handshake crosses the real socket /// and the response returns on the CLI stdout. + #[ignore = "requires local socket bind permission"] #[tokio::test] async fn end_to_end_over_real_loopback() { let endpoint = temp_endpoint("e2e"); diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 298c2c9..56ff561 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -1,95 +1,24 @@ -//! Managed application state — the product of the **composition root**. +//! Tauri-managed application state. //! -//! The composition root ([`build_app_state`]) is the *single* place that -//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as -//! `Arc` into the use cases (ARCHITECTURE §1.1, §10). The use cases -//! are then exposed through `tauri::State` to the command handlers. +//! The non-Tauri composition root lives in `backend::BackendCore`. This type is +//! the desktop driving-adapter envelope: it adds Tauri transport bridges and +//! OS-window focus state, then dereferences to the shared core so existing +//! commands keep their field/method access unchanged. -use std::collections::{HashMap, HashSet}; -use std::ffi::OsString; -use std::path::Path; +use std::ops::Deref; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use application::{ - AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, - AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, - CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, - CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, - DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, - DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, - DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, - GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, - ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, - ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, - LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, - SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, - StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, -}; -use async_trait::async_trait; -use domain::ports::{ - AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, - AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, - EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, - IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, - ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, - StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, - WindowStateStore, -}; -use domain::profile::{ - AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, -}; -use domain::remote::RemoteKind; -use domain::{ - AgentId, AgentInbox, BackgroundTask, BackgroundTaskWakePolicy, DomainEvent, EmbedderProfile, - InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId, - TaskId, TicketId, -}; +use backend::BackendCore; +use infrastructure::TicketToolProvider; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use uuid::Uuid; - -use infrastructure::{ - embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, - BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, - CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, - FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, - FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, - MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, - TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, -}; use crate::chat::ChatBridge; -use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; -use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; use crate::pty::PtyBridge; use crate::tickets::AppTicketToolProvider; +pub use backend::ResumeContext; + /// Focused project snapshot shared with detached panel windows. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -102,2314 +31,42 @@ pub struct FocusedProjectDto { pub root: String, } -use infrastructure::StdioTransport; - -use interprocess::local_socket::tokio::Listener as LocalSocketListener; -use interprocess::local_socket::traits::tokio::Listener as _; -use interprocess::local_socket::{GenericFilePath, ListenerOptions, ToFsName}; -use tokio::io::{AsyncBufReadExt, BufReader}; - -/// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`] -/// ciblant le **project root** du tour en cours. -/// -/// L'[`OrchestratorService`] est unique pour tous les projets, alors que le log/handoff -/// conversationnel est **par project root** (`/.ideai/conversations/`). Les -/// adapters `Fs*` fixent leur racine à la construction et ne sont que des jointures de -/// chemin : on en construit donc un jeu frais par tour, ciblant le bon dossier. Sans -/// état (zéro champ), partagé via un simple `Arc`. -struct AppRecordTurnProvider; - -impl RecordTurnProvider for AppRecordTurnProvider { - fn record_turn_for(&self, root: &domain::project::ProjectPath) -> Option> { - let log = Arc::new(FsConversationLog::new(root)); - let handoffs = Arc::new(FsHandoffStore::new(root)); - let summarizer = Arc::new(HeuristicHandoffSummarizer::new()); - Some(Arc::new(RecordTurn::new(log, handoffs, summarizer))) - } -} - -/// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider) -/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi -/// [`domain::ConversationArchive`]) ciblant le **project root** courant. -/// -/// Même raison d'être que [`AppRecordTurnProvider`] : les use cases d'archivage/pagination -/// sont uniques pour tous les projets, alors que les logs sont **par project root**. On -/// construit donc un archive frais par appel, ciblant le bon dossier. Sans état. -struct AppConversationArchiveProvider; - -impl application::ConversationArchiveProvider for AppConversationArchiveProvider { - fn conversation_archive_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(Arc::new(FsConversationLog::new(root)) as Arc) - } -} - -/// Implémente [`HandoffProvider`](application::HandoffProvider) (lot P7) en -/// matérialisant un [`FsHandoffStore`] ciblant le **project root** du lancement en -/// cours. -/// -/// Même raison d'être que [`AppRecordTurnProvider`] : [`LaunchAgent`] est unique pour -/// tous les projets, alors que le handoff est **par project root** -/// (`/.ideai/conversations/`). On construit donc un store frais par lancement, -/// ciblant le bon dossier. Sans état (zéro champ), partagé via un simple `Arc`. -struct AppHandoffProvider; - -impl application::HandoffProvider for AppHandoffProvider { - fn handoff_store_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(Arc::new(FsHandoffStore::new(root)) as Arc) - } -} - -/// Implémente [`ConversationLogProvider`](application::ConversationLogProvider) (Lot C) -/// en matérialisant un [`FsConversationLog`] ciblant le **project root** courant. -/// -/// Jumeau stateless de [`AppHandoffProvider`] : le read-model work-state est unique -/// pour tous les projets, alors que le log est **par project root** -/// (`/.ideai/conversations/`). On construit donc un log frais par appel, ciblant -/// le bon dossier. Sert de **repli** lecture seule (`last(_, 3)`) aux résumés de -/// conversation. Sans état (zéro champ), partagé via un simple `Arc`. -struct AppConversationLogProvider; - -impl application::ConversationLogProvider for AppConversationLogProvider { - fn conversation_log_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(Arc::new(FsConversationLog::new(root)) as Arc) - } -} - -/// Implémente [`LiveStateProvider`] (programme live-state, lot LS3) en matérialisant un -/// [`UpdateLiveState`] dont le [`FsLiveStateStore`] cible le **project root** du tour. -/// -/// Même raison d'être que [`AppRecordTurnProvider`] : l'[`OrchestratorService`] est -/// unique pour tous les projets, alors que le live-state est **par project root** -/// (`/.ideai/live-state.json`) et le store fixe sa racine à la construction. On -/// construit donc un `UpdateLiveState` frais par transition, ciblant le bon dossier. -/// Porte l'horloge (port [`Clock`]) injectée au composition root — `UpdateLiveState` -/// l'utilise pour estampiller `updated_at_ms`. -struct AppLiveStateProvider { - clock: Arc, -} - -impl LiveStateProvider for AppLiveStateProvider { - fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option> { - let store = Arc::new(FsLiveStateStore::new(root)); - Some(Arc::new(UpdateLiveState::new( - store, - Arc::clone(&self.clock), - ))) - } -} - -/// Implémente [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et** -/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un -/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant. -/// -/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et -/// l'[`OrchestratorService`] sont uniques pour tous les projets, alors que le live-state -/// est **par project root** (`/.ideai/live-state.json`) et le store fixe sa racine -/// à la construction. On construit donc un `GetLiveStateLean` frais par appel, ciblant le -/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]). -struct AppLiveStateLeanProvider { - clock: Arc, -} - -impl AppLiveStateLeanProvider { - fn getter(&self, root: &domain::project::ProjectPath) -> Arc { - let store = Arc::new(FsLiveStateStore::new(root)); - Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock))) - } -} - -impl LiveStateLeanProvider for AppLiveStateLeanProvider { - fn live_state_lean_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(self.getter(root)) - } -} - -impl LiveStateReadProvider for AppLiveStateLeanProvider { - fn live_state_lean_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(self.getter(root)) - } -} - -/// Provider par-root de la réconciliation du live-state au reboot (jumeau de -/// [`AppLiveStateProvider`] côté système). Résout le **project root** depuis le -/// `project_id` via le [`ProjectStore`], matérialise un [`FsLiveStateStore`] ciblant -/// `/.ideai/live-state.json`, puis exécute le use case applicatif -/// [`ReconcileLiveState`] (qui croise chaque ligne avec la liveness réelle de -/// [`LiveSessions`] et ré-upsert les fantômes en `idle`). -/// -/// **Acte système** : appelle le port [`domain::ports::LiveStateStore`] -/// **directement**, jamais via `OrchestratorCommand::SetWorkState` ⇒ le self-only de -/// `idea_workstate_set` reste préservé par construction. Best-effort : le call site -/// (`open_project`) ignore le résultat. -pub(crate) struct AppReconcileLiveState { - projects: Arc, - registry: Arc, - clock: Arc, -} - -impl AppReconcileLiveState { - /// Résout la racine du projet, lie le store par-root et lance la réconciliation. - /// - /// # Errors - /// [`AppError`] si le projet est inconnu (registre) ou si le store échoue. - pub(crate) async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> { - let project = self.projects.load_project(input.project_id).await?; - let store = Arc::new(FsLiveStateStore::new(&project.root)); - let uc = ReconcileLiveState::new( - store, - Arc::clone(&self.registry) as Arc, - Arc::clone(&self.clock), - ); - uc.execute(input).await - } -} - -/// Store global injecté à l'[`OrchestratorService`] qui route chaque opération vers -/// un [`FsBackgroundTaskStore`] lié au root du projet concerné. -/// -/// Le service orchestrateur est unique et multi-projets, alors que -/// `FsBackgroundTaskStore` fixe `/.ideai/background-tasks` à la construction. -/// Ce routeur garde donc la frontière per-root sans instancier un store global sur -/// une racine arbitraire. -struct AppBackgroundTaskStore { - projects: Arc, - stores: Mutex>>, - task_projects: Mutex>, -} - -impl AppBackgroundTaskStore { - fn new(projects: Arc) -> Self { - Self { - projects, - stores: Mutex::new(HashMap::new()), - task_projects: Mutex::new(HashMap::new()), - } - } - - async fn store_for_project( - &self, - project_id: ProjectId, - ) -> Result, BackgroundTaskPortError> { - if let Some(store) = self - .stores - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(&project_id) - .cloned() - { - return Ok(store); - } - - let project = self - .projects - .load_project(project_id) - .await - .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?; - let store = Arc::new(FsBackgroundTaskStore::new(&project.root)); - self.stores - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(project_id, Arc::clone(&store)); - Ok(store) - } - - fn remember(&self, task_id: TaskId, project_id: ProjectId) { - self.task_projects - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(task_id, project_id); - } - - async fn project_for_task( - &self, - task_id: TaskId, - ) -> Result, BackgroundTaskPortError> { - if let Some(project_id) = self - .task_projects - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(&task_id) - .copied() - { - return Ok(Some(project_id)); - } - - for project in self - .projects - .list_projects() - .await - .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? - { - let store = self.store_for_project(project.id).await?; - if store.get(task_id).await?.is_some() { - self.remember(task_id, project.id); - return Ok(Some(project.id)); - } - } - Ok(None) - } - - async fn list_undelivered_for_project( - &self, - project_id: ProjectId, - ) -> Result, BackgroundTaskPortError> { - let store = self.store_for_project(project_id).await?; - let tasks = store.list_undelivered_completions().await?; - for task in &tasks { - self.remember(task.id, task.project_id); - } - Ok(tasks) - } - - async fn reconcile_project_boot( - &self, - project_id: ProjectId, - live_task_ids: &[TaskId], - now_ms: u64, - ) -> Result<(), BackgroundTaskPortError> { - let store = self.store_for_project(project_id).await?; - let report = store.reconcile_boot(live_task_ids, now_ms).await?; - for task_id in report - .failed_task_ids - .into_iter() - .chain(report.delivery_pending_task_ids.into_iter()) - { - self.remember(task_id, project_id); - } - Ok(()) - } -} - -#[async_trait] -impl BackgroundTaskStore for AppBackgroundTaskStore { - async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { - let store = self.store_for_project(task.project_id).await?; - store.create(task).await?; - self.remember(task.id, task.project_id); - Ok(()) - } - - async fn get(&self, id: TaskId) -> Result, BackgroundTaskPortError> { - let Some(project_id) = self.project_for_task(id).await? else { - return Ok(None); - }; - let task = self.store_for_project(project_id).await?.get(id).await?; - if let Some(task) = &task { - self.remember(task.id, task.project_id); - } - Ok(task) - } - - async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { - let store = self.store_for_project(task.project_id).await?; - store.save(task).await?; - self.remember(task.id, task.project_id); - Ok(()) - } - - async fn list_open_for_agent( - &self, - agent_id: AgentId, - ) -> Result, BackgroundTaskPortError> { - let mut tasks = Vec::new(); - for project in self - .projects - .list_projects() - .await - .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? - { - let store = self.store_for_project(project.id).await?; - tasks.extend(store.list_open_for_agent(agent_id).await?); - } - for task in &tasks { - self.remember(task.id, task.project_id); - } - tasks.sort_by_key(|task| (task.created_at_ms, task.id)); - Ok(tasks) - } - - async fn list_undelivered_completions( - &self, - ) -> Result, BackgroundTaskPortError> { - let mut tasks = Vec::new(); - for project in self - .projects - .list_projects() - .await - .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? - { - tasks.extend(self.list_undelivered_for_project(project.id).await?); - } - tasks.sort_by_key(|task| (task.updated_at_ms, task.id)); - Ok(tasks) - } - - async fn mark_completion_delivered( - &self, - task_id: TaskId, - ) -> Result<(), BackgroundTaskPortError> { - let Some(project_id) = self.project_for_task(task_id).await? else { - return Err(BackgroundTaskPortError::NotFound); - }; - self.store_for_project(project_id) - .await? - .mark_completion_delivered(task_id) - .await - } -} - -pub(crate) struct AppReconcileBackgroundTasks { - projects: Arc, - store: Arc, - clock: Arc, - ready: tokio::sync::mpsc::UnboundedSender, -} - -impl AppReconcileBackgroundTasks { - pub(crate) async fn execute(&self, project_id: ProjectId) -> Result<(), AppError> { - let project = self.projects.load_project(project_id).await?; - self.store - .reconcile_project_boot(project_id, &[], self.clock.now_millis().max(0) as u64) - .await - .map_err(|err| AppError::Store(err.to_string()))?; - - let tasks = self - .store - .list_undelivered_for_project(project_id) - .await - .map_err(|err| AppError::Store(err.to_string()))?; - for task in tasks { - match task.wake_policy { - BackgroundTaskWakePolicy::WakeOwner => { - let ready = BackgroundTaskReadyToDeliver { - task_id: task.id, - project_id: task.project_id, - owner_agent_id: task.owner_agent_id, - }; - if self.ready.send(ready).is_err() { - return Err(AppError::Internal( - "background task delivery channel is closed".to_owned(), - )); - } - } - BackgroundTaskWakePolicy::RecordOnly => { - self.store - .mark_completion_delivered(task.id) - .await - .map_err(|err| AppError::Store(err.to_string()))?; - } - } - } - - application::diag!( - "[background-task] boot reconcile complete: project={} root={}", - project.id, - project.root.as_str() - ); - Ok(()) - } -} - -fn schedule_background_ready_retry( - ready_tx: tokio::sync::mpsc::UnboundedSender, - ready: BackgroundTaskReadyToDeliver, -) { - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - let _ = ready_tx.send(ready); - }); -} - -fn schedule_background_wake_retry( - wake: Arc, - project: Project, - owner_agent_id: AgentId, - task_id: TaskId, -) { - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - match wake - .wake_agent( - &project, - owner_agent_id, - WakeReason::BackgroundCompletion { task_id }, - ) - .await - { - Ok(()) => break, - Err(WakeError::AgentBusy { .. }) => continue, - Err(err) => { - application::diag!( - "[background-task] completion wake retry failed: task={} owner={} err={err}", - task_id, - owner_agent_id - ); - break; - } - } - } - }); -} - -struct AppWakeSessionProvider { - launch_agent: Arc, - structured_sessions: Arc, -} - -#[async_trait] -impl WakeSessionProvider for AppWakeSessionProvider { - async fn session_for_wake( - &self, - project: &Project, - agent: AgentId, - ) -> Result, WakeError> { - if let Some(session) = self.structured_sessions.session_for_agent(&agent) { - return Ok(session); - } - - self.launch_agent - .execute(LaunchAgentInput { - project: project.clone(), - agent_id: agent, - rows: 24, - cols: 80, - node_id: None, - conversation_id: None, - mcp_runtime: None, - allow_structured_alongside_pty: true, - }) - .await - .map_err(|err| WakeError::Session(err.to_string()))?; - - self.structured_sessions - .session_for_agent(&agent) - .ok_or_else(|| { - WakeError::Session(format!( - "agent {agent} has no structured session after background wake launch" - )) - }) - } -} - -/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot -/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du -/// lancement en cours. -/// -/// Jumeau stateless de [`AppHandoffProvider`] : [`LaunchAgent`] est unique pour tous -/// les projets, alors que `providers.json` est **par project root** -/// (`/.ideai/conversations/providers.json`). On construit donc un store frais -/// par lancement, ciblant le bon dossier. Sans état (zéro champ), partagé via `Arc`. -struct AppProviderSessionProvider; - -impl application::ProviderSessionProvider for AppProviderSessionProvider { - fn provider_session_store_for( - &self, - root: &domain::project::ProjectPath, - ) -> Option> { - Some(Arc::new(FsProviderSessionStore::new(root)) as Arc) - } -} - -/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5). -/// -/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le -/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige. -/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce -/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour -/// recomposer un lancement complet. -#[derive(Clone)] -pub struct ResumeContext { - /// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`). - pub project: Project, - /// Hauteur de la cellule au dernier lancement (lignes PTY). - pub rows: u16, - /// Largeur de la cellule au dernier lancement (colonnes PTY). - pub cols: u16, -} - -/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande -/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé -/// sur [`AppState`] pour que la commande l'alimente à chaque lancement. -pub type ResumeContexts = Arc>>; - -/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de -/// lancement existant ([`LaunchAgent`]). -/// -/// À l'échéance d'une limite de session, [`SessionLimitService::execute_resume`] -/// délègue ici : on relit le [`ResumeContext`] alimenté par `launch_agent`, on -/// recompose un [`LaunchAgentInput`] (avec le `conversation_id` de la cellule ⇒ -/// `LaunchAgent` applique [`domain::ports::SessionPlan::Resume`]), puis on transmet le -/// `resume_prompt` comme **premier tour** via le portail d'entrée unique -/// ([`InputMediator`](domain::input::InputMediator)) — jamais un write PTY brut -/// (ARCHITECTURE §20). Sans contexte connu (resume « à l'aveugle »), on échoue -/// proprement (l'erreur empêche `AgentResumed` d'être publié), jamais de panique. -struct AppAgentResumer { - /// Le **même** `Arc` que la commande `launch_agent` (relance/réattache). - launch_agent: Arc, - /// Portail d'entrée unique : injecte le prompt de reprise comme premier tour. - input_mediator: Arc, - /// Contexte de relance alimenté par la commande `launch_agent`. - contexts: ResumeContexts, -} - -#[async_trait::async_trait] -impl AgentResumer for AppAgentResumer { - async fn resume( - &self, - agent_id: AgentId, - node_id: domain::NodeId, - conversation_id: Option, - resume_prompt: &str, - ) -> Result<(), AppError> { - // Repli propre (jamais de panique) : sans contexte de relance connu, on ne - // reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié. - let ctx = self - .contexts - .lock() - .ok() - .and_then(|m| m.get(&agent_id).cloned()); - let Some(ctx) = ctx else { - return Err(AppError::NotFound(format!( - "resume context for agent {agent_id}" - ))); - }; - - // Recompose la déclaration MCP réelle (même recette que la commande - // `launch_agent`) pour que l'agent repris retrouve ses outils `idea_*`. - let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { - exe, - endpoint: crate::mcp_endpoint::mcp_endpoint(&ctx.project.id) - .as_cli_arg() - .to_owned(), - project_id: ctx.project.id.as_uuid().simple().to_string(), - requester: agent_id.to_string(), - }); - - self.launch_agent - .execute(LaunchAgentInput { - project: ctx.project, - agent_id, - rows: ctx.rows, - cols: ctx.cols, - node_id: Some(node_id), - conversation_id, - mcp_runtime, - allow_structured_alongside_pty: false, - }) - .await?; - - // Premier tour de reprise par le **portail d'entrée** (pas de write brut, §20) : - // l'enqueue publie `DelegationReady`, la cellule l'écrit quand le prompt est prêt. - // On ne corrèle aucune réponse (reprise, pas une délégation) ⇒ on lâche le - // `PendingReply`. - let ticket = domain::mailbox::Ticket::new( - domain::mailbox::TicketId::new_random(), - "IdeA", - resume_prompt, - ); - let _ = self.input_mediator.enqueue(agent_id, ticket); - - Ok(()) - } -} - -/// Everything the IPC layer needs at runtime, managed by Tauri. -/// -/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete -/// adapters are owned here and never leak past the composition root as concrete -/// types — downstream code only sees the `Arc` held inside the use -/// cases. +/// Desktop adapter state managed by Tauri. pub struct AppState { - /// Trivial health use case validating the end-to-end wiring. - pub health: Arc, - /// Create a project (init `.ideai/`, register it). - pub create_project: Arc, - /// Open a project (load meta + manifest). - pub open_project: Arc, - /// Close a project (persist state). - pub close_project: Arc, - /// Close a tab. - pub close_tab: Arc, - /// List known projects. - pub list_projects: Arc, - /// Read `.ideai/CONTEXT.md`. - pub read_project_context: Arc, - /// Overwrite `.ideai/CONTEXT.md`. - pub update_project_context: Arc, - /// Open a terminal (spawn PTY, register session). - pub open_terminal: Arc, - /// Write keystrokes to a terminal. - pub write_terminal: Arc, - /// Resize a terminal. - pub resize_terminal: Arc, - /// Close a terminal (kill PTY). - pub close_terminal: Arc, - /// Load a project's persisted layout tree. - pub load_layout: Arc, - /// Mutate + persist a project's layout tree. - pub mutate_layout: Arc, - /// List all named layouts for a project (#4). - pub list_layouts: Arc, - /// Create a new named layout (#4). - pub create_layout: Arc, - /// Rename a named layout (#4). - pub rename_layout: Arc, - /// Delete a named layout (#4). - pub delete_layout: Arc, - /// Set the active named layout (#4). - pub set_active_layout: Arc, - /// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5). - pub snapshot_running_agents: Arc, - /// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même - /// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon. - pub reconcile_layouts: Arc, - /// Réconcilie, à l'ouverture, les lignes de live-state fantômes (agent - /// `working`/`waiting`/`blocked` dont la session n'est plus vivante) : les - /// repasse en `idle`. Acte système best-effort, jamais via la surface MCP. - pub(crate) reconcile_live_state: Arc, - /// Réconcilie, à l'ouverture, les tâches de fond persistées : marque les - /// `Running` sans handle vivant comme perdues, ré-enqueue les complétions - /// `WakeOwner` non livrées et ferme les `RecordOnly`. - pub(crate) reconcile_background_tasks: Arc, - /// Detect which candidate profiles' CLIs are installed (first-run). - pub detect_profiles: Arc, - /// List configured profiles. - pub list_profiles: Arc, - /// Save (upsert) a profile. - pub save_profile: Arc, - /// Create a new OpenCode profile instance from the canonical seed. - pub clone_opencode_profile_from_seed: Arc, - /// Delete a profile. - pub delete_profile: Arc, - /// Persist the batch of chosen profiles (closes the first run). - pub configure_profiles: Arc, - /// Expose the pre-filled reference catalogue. - pub reference_profiles: Arc, - /// Whether the first-run wizard should show + the reference catalogue. - pub first_run_state: Arc, - /// Ensures local llama.cpp model servers for OpenCode profiles. - pub ensure_local_model_server: Arc, - /// Lists local model server configurations. - pub list_model_servers: Arc, - /// Saves local model server configurations. - pub save_model_server: Arc, - /// Deletes local model server configurations when unused. - pub delete_model_server: Arc, - /// The local PTY adapter, kept port-typed so the presentation layer can - /// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport). - pub pty_port: Arc, - /// Active-terminal registry shared by the terminal use cases. - pub terminal_sessions: Arc, - /// The domain event bus (also handed to the event relay). - pub event_bus: Arc, - /// Create an issue-backed public ticket. - pub create_issue: Arc, - /// Read an issue-backed public ticket. - pub read_issue: Arc, - /// Delete an issue-backed public ticket. - pub delete_issue: Arc, - /// List issue-backed public tickets. - pub list_issues: Arc, - /// Update an issue-backed public ticket. - pub update_issue: Arc, - /// Read a ticket carnet. - pub read_issue_carnet: Arc, - /// Update a ticket carnet. - pub update_issue_carnet: Arc, - /// Link two public tickets. - pub link_issues: Arc, - /// Unlink public tickets. - pub unlink_issues: Arc, - /// Assign or unassign an agent on a public ticket. - pub assign_issue_agent: Arc, - /// Create a sprint. - pub create_sprint: Arc, - /// List sprints. - pub list_sprints: Arc, - /// Rename a sprint. - pub rename_sprint: Arc, - /// Reorder sprints. - pub reorder_sprints: Arc, - /// Delete a sprint. - pub delete_sprint: Arc, - /// Assign a ticket to a sprint. - pub assign_ticket_to_sprint: Arc, - /// Unassign a ticket from its sprint. - pub unassign_ticket_from_sprint: Arc, - /// MCP provider for the public `idea_ticket_*` tools. - pub(crate) ticket_tool_provider: Arc, - /// Open an ephemeral AI assistant chat bound to one ticket. - pub open_ticket_assistant: Arc, - /// Close an ephemeral AI assistant chat bound to one ticket. - pub close_ticket_assistant: Arc, - /// Shared MCP tool policy registry used by ticket assistant sessions. - pub tool_policy_registry: Arc, - /// Generic PTY↔Channel bridge registry (consumed by L3). + core: Arc, + /// Generic PTY to Tauri Channel bridge registry. pub pty_bridge: Arc, - /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec - /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) - /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. - pub structured_sessions: Arc, - /// Pont réponses structurées ↔ Channel (jumeau de [`PtyBridge`], §17.7). Route - /// les [`ReplyChunk`](crate::dto::ReplyChunk) d'un tour vers la bonne cellule - /// chat et retient le scrollback de conversation pour la ré-attache. + /// Structured reply to Tauri Channel bridge registry. pub chat_bridge: Arc, - // --- Agents (L6) --- - /// Create a project agent from scratch. - pub create_agent: Arc, - /// List a project's agents. - pub list_agents: Arc, - /// Read an agent's Markdown context. - pub read_agent_context: Arc, - /// Overwrite an agent's Markdown context. - pub update_agent_context: Arc, - /// Delete an agent from the manifest. - pub delete_agent: Arc, - /// Launch an agent (spawn PTY, apply injection strategy). - pub launch_agent: Arc, - /// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1). - pub change_agent_profile: Arc, - /// Read-only inventory of a project's resumable agent cells, for the reopen - /// panel (§15.2). - pub list_resumable_agents: Arc, - /// Read-only live/busy state for the project's manifest agents. - pub get_project_work_state: Arc, - /// Human paginated read of a conversation's full transcript (lot LS6). - pub read_conversation_page: Arc, - /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). - pub rotate_conversation_log: Arc, - /// Rebinds an already-running agent's live session to a visible cell (Lot D). - pub attach_live_agent: Arc, - /// Tears down an already-running agent's live session by agent id (Lot D). - pub stop_live_agent: Arc, - /// Best-effort inspection of a conversation (last topic + token indicator) - /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of - /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty - /// details, never an error. - pub inspect_conversation: Arc, - /// Project registry — used by agent commands to resolve a `Project` from an id. - pub project_store: Arc, - /// Read the project permission document. - pub get_project_permissions: Arc, - /// Update project-level default permissions. - pub update_project_permissions: Arc, - /// Update one agent permission override. - pub update_agent_permissions: Arc, - /// Resolve effective permissions for one agent. - pub resolve_agent_permissions: Arc, - // --- Windows (L10) --- - /// Detach a tab into a new OS window (persists the workspace topology). - pub move_tab: Arc, - /// Persist open Tauri webview-window state on main-window shutdown. - pub snapshot_open_windows: Arc, - /// Load restorable Tauri webview-window state on startup. - pub restore_open_windows: Arc, /// Project currently focused by the main window; panel-only windows follow it. focused_project: Mutex>, - // --- Background tasks (B8) --- - /// Spawn a command-backed first-class background task. - pub spawn_background_command: Arc, - /// Cancel a running background task. - pub cancel_background_task: Arc, - /// Retry a terminal command task under a fresh task id. - pub retry_background_task: Arc, - /// Store handle used by `list_background_tasks` to read the task read-model. - pub background_task_store: Arc, - // --- Templates & sync (L7) --- - /// Create a template in the global store. - pub create_template: Arc, - /// Update a template's content (bumps version). - pub update_template: Arc, - /// List all templates in the global store. - pub list_templates: Arc, - /// Delete a template from the global store. - pub delete_template: Arc, - /// Create an agent from a template. - pub create_agent_from_template: Arc, - /// Detect which synchronized agents are behind their template. - pub detect_agent_drift: Arc, - /// Apply a template update to a synchronized agent. - pub sync_agent_with_template: Arc, - // --- Git (L8) --- - /// Report the working-tree status of a repository. - pub git_status: Arc, - /// Stage a path. - pub git_stage: Arc, - /// Unstage a path. - pub git_unstage: Arc, - /// Create a commit. - pub git_commit: Arc, - /// List branches. - pub git_branches: Arc, - /// Check out a branch. - pub git_checkout: Arc, - /// Return the recent commit log. - pub git_log: Arc, - /// Initialise a repository. - pub git_init: Arc, - /// Return the commit graph for all local branches. - pub git_graph: Arc, - // --- Skills (L12) --- - /// Create a skill in a scope's store. - pub create_skill: Arc, - /// Update a skill's content. - pub update_skill: Arc, - /// List skills in a scope. - pub list_skills: Arc, - /// Delete a skill from its scope's store. - pub delete_skill: Arc, - /// Assign a skill to an agent (records a `SkillRef`). - pub assign_skill: Arc, - /// Unassign a skill from an agent. - pub unassign_skill: Arc, - - // --- Memory (LOT A — §14.5.1) --- - /// Create a memory note in the project's store. - pub create_memory: Arc, - /// Replace an existing memory note. - pub update_memory: Arc, - /// List the project's memory notes. - pub list_memories: Arc, - /// Read one memory note by slug. - pub get_memory: Arc, - /// Delete a memory note. - pub delete_memory: Arc, - /// Read the structured `MEMORY.md` index. - pub read_memory_index: Arc, - /// Resolve a note's outgoing `[[slug]]` links. - pub resolve_memory_links: Arc, - /// Recall the most relevant memory entries for a query within a budget - /// (LOT B — §14.5.2). - pub recall_memory: Arc, - // --- Embedder config (LOT C2 — §14.5.3) --- - /// List the configured embedder profiles (`embedder.json`). - pub list_embedder_profiles: Arc, - /// Save (upsert, validating) an embedder profile. - pub save_embedder_profile: Arc, - /// Delete an embedder profile by id. - pub delete_embedder_profile: Arc, - /// Describe the embedding engines available to the configuration UI (catalogue - /// + detected local environment + compiled-in capabilities). - pub describe_embedder_engines: Arc, - // --- Embedder suggestion (LOT C3 — §14.5.5) --- - /// Persist the user's response to the embedder suggestion (`later`/`never`). - pub dismiss_embedder_suggestion: Arc, - // --- Orchestrator (§14.3) --- - /// Dispatches validated orchestrator requests to the agent/skill use cases. - /// Shared by every per-project filesystem watcher. - pub orchestrator_service: Arc, - /// Live orchestrator request watchers, keyed by project. One watcher per open - /// project tails its `.ideai/requests/` tree; dropping the handle stops it. - /// Guarded by a `Mutex` so the open/close commands can register/unregister - /// watchers concurrently. - pub orchestrator_watchers: Mutex>, - /// Live IdeA MCP servers, keyed by project — the **twin** of - /// [`orchestrator_watchers`](Self::orchestrator_watchers). One server per open - /// project is the MCP entry door onto the *same* [`OrchestratorService`] the - /// file watcher feeds; both coexist (Décision 4). Started on open/create and - /// stopped on close, exactly like the watcher, via the same `Mutex`-guarded - /// per-project registry. - pub mcp_servers: Mutex>, - /// Service de gestion des **limites de session** des agents (ARCHITECTURE §21) : - /// détecte → planifie → reprend, et annule. Alimenté par les taps niveau 1 - /// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est - /// annulable via la commande `cancel_resume`. - pub session_limit_service: Arc, - /// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] : - /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour - /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. - pub resume_contexts: ResumeContexts, - /// Observateur de **fin de tour** (backstop no-reply) : lit le transcript on-disk de - /// l'agent (Claude `turn_duration`) et appelle `InputMediator::turn_ended`. Remplace - /// le watcher prompt-ready PTY mort. Armé par agent supporté au lancement. - pub turn_watcher: Arc, - /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du - /// [`turn_watcher`](Self::turn_watcher) à l'armement. - pub turn_watch_input: Arc, - /// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent - /// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter - /// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. - pub turn_watch_handles: Mutex>>, - /// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous - /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que - /// l'inspecteur et le turn-watcher. - pub fs_port: Arc, - /// Répertoire home (`$HOME`) servant à dériver `/.claude/projects/` - /// pour la sonde d'activité du rendez-vous. - pub home_dir: String, } impl AppState { - /// **Composition root.** Builds all adapters and use cases. - /// - /// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE - /// §9.2), resolved by the caller via the Tauri path API and injected here so - /// the stores never touch Tauri themselves (Dependency Inversion). - /// - /// This is the only function that constructs concrete adapters; every other - /// layer depends on ports. Adapters added in later lots (PTY, git, remote) - /// are wired in here. + /// Builds the shared backend core and wraps it with desktop-only transport + /// state. `app_data_dir` is resolved by the Tauri adapter before crossing + /// into the backend core. #[must_use] pub fn build(app_data_dir: PathBuf) -> Self { - // --- Concrete adapters (driven adapters) --- - let event_bus = Arc::new(TokioBroadcastEventBus::new()); - let clock = Arc::new(SystemClock::new()); - let ids = Arc::new(UuidGenerator::new()); - let fs = Arc::new(LocalFileSystem::new()); - let store = Arc::new(FsProjectStore::new( - Arc::clone(&fs) as Arc, - app_data_dir.to_string_lossy().into_owned(), - )); - let window_state_store = Arc::new(FsWindowStateStore::new( - Arc::clone(&fs) as Arc, - app_data_dir.to_string_lossy().into_owned(), - )); - - // Port-typed handles for injection. - let fs_port = Arc::clone(&fs) as Arc; - let store_port = Arc::clone(&store) as Arc; - let window_state_port = Arc::clone(&window_state_store) as Arc; - let events_port = Arc::clone(&event_bus) as Arc; - - // --- Use cases (ports injected as Arc) --- - let health = Arc::new(HealthUseCase::new( - Arc::clone(&clock) as Arc, - Arc::clone(&ids) as Arc, - Arc::clone(&events_port), - )); - - let create_project = Arc::new(CreateProject::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&ids) as Arc, - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let open_project = Arc::new(OpenProject::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - )); - let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port))); - let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port))); - let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port))); - let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port))); - let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port))); - - // --- PTY adapter + terminal use cases (L3) --- - let pty = Arc::new( - PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()), - ); - let pty_port = Arc::clone(&pty) as Arc; - let terminal_sessions = Arc::new(TerminalSessions::new()); - - // --- Sessions structurées (IA / cellules chat, §17) --- - // Registre jumeau de TerminalSessions + fabrique infra routée par - // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et - // ChangeAgentProfile (shutdown polymorphe au hot-swap). - let structured_sessions = Arc::new(StructuredSessions::new()); - let openai_tool_invoker = Arc::new(LateBoundOpenAiToolInvoker::new()); - let openai_tool_invoker_port = Arc::clone(&openai_tool_invoker) as Arc; - let session_factory = Arc::new( - StructuredSessionFactory::new() - .with_sandbox_enforcer(infrastructure::default_enforcer()) - .with_tool_invoker(openai_tool_invoker_port), - ) as Arc; - - let open_terminal = Arc::new(OpenTerminal::new( - Arc::clone(&pty_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - )); - let write_terminal = Arc::new(WriteToTerminal::new( - Arc::clone(&pty_port), - Arc::clone(&terminal_sessions), - )); - let resize_terminal = Arc::new(ResizeTerminal::new( - Arc::clone(&pty_port), - Arc::clone(&terminal_sessions), - )); - let close_terminal = Arc::new(CloseTerminal::new( - Arc::clone(&pty_port), - Arc::clone(&terminal_sessions), - )); - - // --- Layout use cases (L4 + #4) --- - let load_layout = Arc::new(LoadLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - )); - let mutate_layout = Arc::new(MutateLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&events_port), - )); - let list_layouts = Arc::new(ListLayouts::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - )); - let create_layout = Arc::new(CreateLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&ids) as Arc, - Arc::clone(&events_port), - )); - let rename_layout = Arc::new(RenameLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&events_port), - )); - let delete_layout = Arc::new(DeleteLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&events_port), - )); - let set_active_layout = Arc::new(SetActiveLayout::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&events_port), - )); - // Close-time snapshot of running agents (T5). Shares the SAME live-session - // registry as the terminal/agent use cases, so its liveness check reflects - // the very PTYs the shutdown hook is about to kill — it must run *before*. - let snapshot_running_agents = Arc::new(SnapshotRunningAgents::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&terminal_sessions) as Arc, - )); - - // Twin of the snapshot above, but at *open* time: dé-doublonne les - // `layouts.json` portant plusieurs feuilles sur le même agent (R0c, §3.4 - // « Trou C »). Idempotent : aucun doublon ⇒ aucune écriture. - let reconcile_layouts = Arc::new(ReconcileLayouts::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - )); - - // --- Profiles & AI runtime (L5) --- - // One generic, profile-driven runtime adapter (Open/Closed): it holds the - // process spawner used for detection. The profile store persists - // `profiles.json` in the same machine-local app-data dir as the project - // registry. - let spawner = Arc::new(LocalProcessSpawner::new()); - let spawner_port = Arc::clone(&spawner) as Arc; - let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port))); - let runtime_port = Arc::clone(&runtime) as Arc; - - let profile_store = Arc::new(FsProfileStore::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )); - let profile_store_port: Arc = - Arc::clone(&profile_store) as Arc; - - let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); - let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); - let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port))); - let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( - Arc::clone(&profile_store_port), - Arc::clone(&ids) as Arc, - )); - let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port))); - let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port))); - let reference_profiles = Arc::new(ReferenceProfiles::new()); - let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port))); - - let model_server_registry = Arc::new(FsModelServerRegistry::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )); - let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new( - app_data_dir.join("hf-model-artifacts"), - )); - let ensure_local_model_server = Arc::new( - EnsureLocalModelServer::new( - Arc::clone(&model_server_registry) as Arc, - Arc::new(HttpOpenAiCompatibleProbe::default()) - as Arc, - Arc::new(LocalManagedProcess::new()) as Arc, - Arc::new(LlamaCppRuntime::new()) as Arc, - Arc::clone(&fs_port), - Arc::clone(&events_port), - ) - .with_model_artifact_downloader( - model_artifact_downloader as Arc, - ), - ); - let model_server_registry_port = - Arc::clone(&model_server_registry) as Arc; - let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( - &model_server_registry_port, - ))); - let save_model_server = Arc::new(SaveModelServer::new(Arc::clone( - &model_server_registry_port, - ))); - let delete_model_server = Arc::new(DeleteModelServer::new( - Arc::clone(&model_server_registry_port), - Arc::clone(&profile_store_port), - )); - - let pty_bridge = Arc::new(PtyBridge::new()); - // Twin of the PTY bridge for structured chat sessions (§17.7): routes a - // turn's ReplyChunks to the owning chat cell and retains the conversation - // scrollback for re-attach. - let chat_bridge = Arc::new(ChatBridge::new()); - - // --- Agent context store + use cases (L6) --- - let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port))); - let contexts_port = Arc::clone(&contexts) as Arc; - - // --- Public tickets (Issue domain) --- - // One stateless FS store/allocator serves every project; the project root is - // passed on each port call and resolves to `/.ideai/tickets/`. - let issue_store = Arc::new(FsIssueStore::new()); - let issue_store_port = Arc::clone(&issue_store) as Arc; - let issue_allocator = Arc::new(FsIssueNumberAllocator::new()); - let issue_allocator_port = Arc::clone(&issue_allocator) as Arc; - let create_issue = Arc::new(CreateIssue::new( - Arc::clone(&issue_store_port), - Arc::clone(&issue_allocator_port), - Arc::clone(&contexts_port), - Arc::clone(&ids) as Arc, - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_port))); - let delete_issue = Arc::new(DeleteIssue::new( - Arc::clone(&issue_store_port), - Arc::clone(&events_port), - )); - let list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port))); - let update_issue = Arc::new(UpdateIssue::new( - Arc::clone(&issue_store_port), - Arc::clone(&contexts_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port))); - let update_issue_carnet = Arc::new(UpdateIssueCarnet::new( - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let link_issues = Arc::new(LinkIssues::new( - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let unlink_issues = Arc::new(UnlinkIssues::new( - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let assign_issue_agent = Arc::new(AssignIssueAgent::new( - Arc::clone(&issue_store_port), - Arc::clone(&contexts_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let sprint_store = Arc::new(FsSprintStore::new()); - let sprint_store_port = Arc::clone(&sprint_store) as Arc; - let create_sprint = Arc::new(CreateSprint::new( - Arc::clone(&sprint_store_port), - Arc::clone(&ids) as Arc, - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let list_sprints = Arc::new(ListSprints::new( - Arc::clone(&sprint_store_port), - Arc::clone(&issue_store_port), - )); - let rename_sprint = Arc::new(RenameSprint::new( - Arc::clone(&sprint_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let reorder_sprints = Arc::new(ReorderSprints::new( - Arc::clone(&sprint_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let delete_sprint = Arc::new(DeleteSprint::new( - Arc::clone(&sprint_store_port), - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let assign_ticket_to_sprint = Arc::new(AssignTicketToSprint::new( - Arc::clone(&sprint_store_port), - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new( - Arc::clone(&issue_store_port), - Arc::clone(&clock) as Arc, - Arc::clone(&events_port), - )); - let ticket_tool_provider: Arc = Arc::new(AppTicketToolProvider { - create: Arc::clone(&create_issue), - read: Arc::clone(&read_issue), - list: Arc::clone(&list_issues), - update: Arc::clone(&update_issue), - read_carnet: Arc::clone(&read_issue_carnet), - update_carnet: Arc::clone(&update_issue_carnet), - link: Arc::clone(&link_issues), - unlink: Arc::clone(&unlink_issues), - list_sprints: Arc::clone(&list_sprints), - }); - let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); - let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; - let assistant_context_provider = Arc::new(FsAssistantContextStore::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )) as Arc; - let assistant_environment = Arc::new(TicketAssistantEnvironmentPreparer::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - Arc::clone(&runtime_port), - Arc::new(|project: &Project, requester: &str| { - Some(McpRuntime { - exe: crate::mcp_endpoint::idea_exe_path()?, - endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) - .as_cli_arg() - .to_owned(), - project_id: project.id.as_uuid().simple().to_string(), - requester: requester.to_owned(), - }) - }), - )) as Arc; - let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( - Arc::clone(&issue_store_port), - Arc::clone(&profile_store_port), - assistant_context_provider, - assistant_environment, - Arc::clone(&session_factory), - Arc::clone(&structured_sessions), - Arc::clone(&tool_policy_store), - Arc::clone(&events_port), - )); - let close_ticket_assistant = Arc::new(CloseTicketAssistant::new( - Arc::clone(&structured_sessions), - tool_policy_store, - Arc::clone(&events_port), - )); - - // --- Project permissions (LP1) --- - let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); - let permission_store_port = Arc::clone(&permission_store) as Arc; - - // --- Skill store (L12) --- - // Global skills live in the machine-local app-data dir; project skills are - // resolved per call from each project's `.ideai/` (so one store serves all - // open projects). Shared by the skill use cases and the agent launcher - // (assigned-skill injection into the convention file, §14.2). - let skill_store = Arc::new(FsSkillStore::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )); - let skill_store_port = Arc::clone(&skill_store) as Arc; - - // Memory store + naïve recall (LOT A/B — §14.5.1/§14.5.4). Built here so the - // recall port can be injected into LaunchAgent below (it composes the project - // memory recall into the convention file at activation); the memory use cases - // are wired further down from these same instances. - let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port))); - let memory_store_port = Arc::clone(&memory_store) as Arc; - // Load the configured embedder profile (mono-profile for now: take the last - // listed, fall back to `none`). A multi-profile selector is a follow-up; the - // `none` default keeps recall strictly naïve and dependency-free. - let embedder_store = Arc::new(FsEmbedderProfileStore::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )); - let embedder_store_port = Arc::clone(&embedder_store) as Arc; - // `build` may run inside an ambient async runtime (Tauri's `setup`, or - // `#[tokio::test]`), so blocking the current thread on a future panics. - // Drive the one-shot load on a dedicated thread with its own runtime. - let embedder_profile = std::thread::scope(|s| { - s.spawn(|| { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .ok() - .and_then(|rt| rt.block_on(embedder_store.list()).ok()) - .and_then(|mut v| v.pop()) - }) - .join() - .ok() - .flatten() - }) - .unwrap_or_else(EmbedderProfile::none); - let onnx_cache_dir = app_data_dir.join(ONNX_CACHE_SUBDIR); - let memory_recall_port = build_memory_recall( - Arc::clone(&fs_port), - Arc::clone(&memory_store_port), - &embedder_profile, - &onnx_cache_dir, - ); - - // --- Embedder configuration use cases (LOT C2 — §14.5.3) --- - // CRUD over `embedder.json` (port-typed store, built above) + a read-only - // description of the available engines. The environment probe shares the SAME - // `onnx_cache_dir` the recall uses, so "is this model cached?" stays coherent. - // The static ONNX catalogue and the compiled-capability flags are owned by - // infrastructure and injected here (the application stays infra-free, DIP). - let env_inspector = Arc::new(EmbedderEnvProbe::new( - onnx_cache_dir.clone(), - DEFAULT_OLLAMA_BASE_URL, - )) as Arc; - // Cloned for the suggestion check below (the original is moved into - // `describe_embedder_engines`). Both share the same probe behaviour. - let env_inspector_for_suggestion = Arc::clone(&env_inspector); - let recommended_onnx: Vec = RECOMMENDED_ONNX_MODELS - .iter() - .map(|m| OnnxModelView { - id: m.id.to_owned(), - display_name: m.display_name.to_owned(), - dimension: m.dimension, - approx_size_mb: m.approx_size_mb, - recommended: m.recommended, - }) - .collect(); - let list_embedder_profiles = - Arc::new(ListEmbedderProfiles::new(Arc::clone(&embedder_store_port))); - let save_embedder_profile = - Arc::new(SaveEmbedderProfile::new(Arc::clone(&embedder_store_port))); - let delete_embedder_profile = - Arc::new(DeleteEmbedderProfile::new(Arc::clone(&embedder_store_port))); - let describe_embedder_engines = Arc::new(DescribeEmbedderEngines::new( - env_inspector, - recommended_onnx, - VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, - )); - - // --- Embedder suggestion (LOT C3 — §14.5.5) --- - // Per-project dismissal state (`.ideai/memory/.embedder-prompt.json`) + - // the in-memory "already suggested this session" guard, shared with the - // launcher's best-effort check. The check publishes EmbedderSuggested at - // most once per session per project, only while strategy is `none` and the - // memory has outgrown the recall budget. - let prompt_store = Arc::new(FsEmbedderPromptStore::new(Arc::clone(&fs_port))); - let prompt_store_port = Arc::clone(&prompt_store) as Arc; - let suggested_this_session: SuggestedThisSession = SuggestedThisSession::default(); - let check_embedder_suggestion = Arc::new(CheckEmbedderSuggestion::new( - Arc::clone(&embedder_store_port), - Arc::clone(&memory_store_port), - Arc::clone(&prompt_store_port), - env_inspector_for_suggestion, - Arc::clone(&events_port), - Arc::clone(&suggested_this_session), - AGENT_MEMORY_RECALL_BUDGET, - VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, - )); - let dismiss_embedder_suggestion = Arc::new(DismissEmbedderSuggestion::new(Arc::clone( - &prompt_store_port, - ))); - - let create_agent = Arc::new(CreateAgentFromScratch::new( - Arc::clone(&contexts_port), - Arc::clone(&ids) as Arc, - Arc::clone(&events_port), - )); - let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port))); - let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port))); - let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port))); - let delete_agent = Arc::new(DeleteAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&events_port), - )); - // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal - // use cases — indispensable for the PtyBridge to work correctly. - // - // The human-facing launcher intentionally stays PTY-only: when the user opens an - // agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent - // delegation gets its own launcher below, wired to structured/headless sessions. - - // --- Permission projectors (lot LP3-5) --- - // UN seul registre, source unique de vérité, injecté à l'identique dans - // `LaunchAgent` (projection au lancement) ET `ChangeAgentProfile` (nettoyage des - // fichiers orphelins au swap). Les deux projecteurs concrets vivent dans - // l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`. - let permission_projectors = Arc::new( - PermissionProjectorRegistry::new() - .with(Arc::new(ClaudePermissionProjector) as Arc) - .with(Arc::new(CodexPermissionProjector) as Arc), - ); - - let launch_agent = Arc::new( - LaunchAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&runtime_port), - Arc::clone(&fs_port), - Arc::clone(&pty_port), - Arc::clone(&skill_store_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - Arc::clone(&ids) as Arc, - Arc::clone(&memory_recall_port), - Some(Arc::clone(&check_embedder_suggestion)), - ) - .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) - .with_permission_store(Arc::clone(&permission_store_port)) - // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule - // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), - // son résumé est réinjecté dans le convention file. Best-effort, additif : - // un handoff absent/illisible ⇒ lancement normal sans section. - .with_handoff_provider( - Arc::new(AppHandoffProvider) as Arc - ) - // Resumable moteur par provider (lot P8b) : après un lancement structuré - // exposant un id de session moteur, rangé sous la clé de paire dans - // `/.ideai/conversations/providers.json`. Best-effort, additif : une - // écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture. - .with_provider_session_provider(Arc::new(AppProviderSessionProvider) - as Arc) - // Projection des permissions au (re)lancement (lot LP3-5) : avec le - // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur - // du profil matérialise la config de permission de la CLI dans le run dir. - .with_permission_projectors(Arc::clone(&permission_projectors)) - // Aperçu live-state des autres agents (lot LS4) : section `# État du projet` - // injectée au lancement. Best-effort strict : provider absent / erreur / - // parse ⇒ section omise, jamais d'échec de lancement. - .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { - clock: Arc::clone(&clock) as Arc, - }) as Arc) - .with_local_model_server(Arc::clone(&ensure_local_model_server)), - ); - - // Inter-agent launcher: same context, memory, permissions and live-state - // injection as the human launcher, but with the structured factory wired. It is - // used only by OrchestratorService::ask_agent when a delegated target must be - // started headlessly; UI launches still go through `launch_agent` above. - let orchestrator_launch_agent = Arc::new( - LaunchAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&runtime_port), - Arc::clone(&fs_port), - Arc::clone(&pty_port), - Arc::clone(&skill_store_port), - Arc::clone(&terminal_sessions), - Arc::clone(&events_port), - Arc::clone(&ids) as Arc, - Arc::clone(&memory_recall_port), - Some(Arc::clone(&check_embedder_suggestion)), - ) - .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) - .with_permission_store(Arc::clone(&permission_store_port)) - .with_handoff_provider( - Arc::new(AppHandoffProvider) as Arc - ) - .with_provider_session_provider(Arc::new(AppProviderSessionProvider) - as Arc) - .with_permission_projectors(Arc::clone(&permission_projectors)) - .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { - clock: Arc::clone(&clock) as Arc, - }) as Arc) - .with_local_model_server(Arc::clone(&ensure_local_model_server)) - .with_structured( - Arc::clone(&session_factory), - Arc::clone(&structured_sessions), - ), - ); - - // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ - // profile/project/fs stores, the live-session registry and PTY port, and - // *composes* the launcher above for the in-place relaunch (no duplication). - // Voit aussi le registre structuré pour un « kill » polymorphe (§17.4). - let change_agent_profile = Arc::new( - ChangeAgentProfile::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&terminal_sessions), - Arc::clone(&pty_port), - Arc::clone(&launch_agent), - Arc::clone(&events_port), - ) - .with_structured(Arc::clone(&structured_sessions)) - // Même registre que `LaunchAgent` (lot LP3-5) : au swap cross-profile, on - // nettoie les fichiers `Replace` orphelins de l'ancien profil avant relance. - .with_permission_projectors(Arc::clone(&permission_projectors)), - ); - - // Read-only inventory of resumable agent cells (§15.2). Reuses the shared - // project/fs/context/profile stores already injected above — no new port. - let list_resumable_agents = Arc::new(ListResumableAgents::new( - Arc::clone(&store_port), - Arc::clone(&fs_port), - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - )); - - // --- Conversation inspection (T7) --- - // Best-effort, optional, extensible: a `Vec` of SessionInspectors routed - // by profile. Adding an inspectable CLI = pushing one more adapter here. - // The Claude inspector reads `/.claude/projects//.jsonl`; - // `$HOME` is resolved from the environment (empty string if unset — the - // inspector then simply finds nothing and yields empty details). - let home_dir = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_default(); - let inspectors: Vec> = vec![Arc::new( - ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()), - )]; - // Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`) - // — lit le même `/.claude/projects//` que l'inspecteur, via - // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`). - let turn_watcher: Arc = - Arc::new(infrastructure::ClaudeTranscriptTurnWatcher::new( - Arc::clone(&fs_port), - home_dir.clone(), - )); - let inspect_conversation = Arc::new(InspectConversation::new( - Arc::clone(&contexts_port), - Arc::clone(&profile_store_port), - inspectors, - )); - - let project_store = Arc::clone(&store_port); - let get_project_permissions = Arc::new(GetProjectPermissions::new(Arc::clone( - &permission_store_port, - ))); - let update_project_permissions = Arc::new(UpdateProjectPermissions::new(Arc::clone( - &permission_store_port, - ))); - let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone( - &permission_store_port, - ))); - let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( - &permission_store_port, - ))); - - // --- Template store + use cases (L7) --- - let template_store = Arc::new(FsTemplateStore::new( - Arc::clone(&fs_port), - app_data_dir.to_string_lossy().into_owned(), - )); - let template_store_port = Arc::clone(&template_store) as Arc; - - let create_template = Arc::new(CreateTemplate::new( - Arc::clone(&template_store_port), - Arc::clone(&ids) as Arc, - )); - let update_template = Arc::new(UpdateTemplate::new( - Arc::clone(&template_store_port), - Arc::clone(&events_port), - )); - let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port))); - let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port))); - let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new( - Arc::clone(&template_store_port), - Arc::clone(&contexts_port), - Arc::clone(&ids) as Arc, - Arc::clone(&events_port), - )); - let detect_agent_drift = Arc::new(DetectAgentDrift::new( - Arc::clone(&template_store_port), - Arc::clone(&contexts_port), - Arc::clone(&events_port), - )); - let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new( - Arc::clone(&template_store_port), - Arc::clone(&contexts_port), - Arc::clone(&events_port), - )); - - // --- Git adapter + use cases (L8) --- - let git = Arc::new(Git2Repository::new()); - let git_port = Arc::clone(&git) as Arc; - - let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port))); - let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port))); - let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port))); - let git_commit = Arc::new(GitCommit::new( - Arc::clone(&git_port), - Arc::clone(&events_port), - )); - let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port))); - let git_checkout = Arc::new(GitCheckout::new( - Arc::clone(&git_port), - Arc::clone(&events_port), - )); - let git_log = Arc::new(GitLog::new(Arc::clone(&git_port))); - let git_init = Arc::new(GitInit::new( - Arc::clone(&git_port), - Arc::clone(&events_port), - )); - let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port))); - - // --- Skill use cases (L12) --- - // Reuse the skill store (built above for the launcher) and the shared - // agent context store for the agent↔skill assignment. - let create_skill = Arc::new(CreateSkill::new( - Arc::clone(&skill_store_port), - Arc::clone(&ids) as Arc, - )); - let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port))); - let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port))); - let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port))); - // Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` (feature - // « skills à la MCP ») — compose le SkillStore existant, câblé plus bas sur - // l'OrchestratorService via le builder additif `.with_read_skill(...)`. - let read_skill = Arc::new(ReadSkill::new(Arc::clone(&skill_store_port))); - let assign_skill = Arc::new(AssignSkillToAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&events_port), - )); - let unassign_skill = Arc::new(UnassignSkillFromAgent::new( - Arc::clone(&contexts_port), - Arc::clone(&events_port), - )); - - // --- Memory use cases (LOT A — §14.5.1) --- - // `memory_store` / `memory_store_port` / `memory_recall_port` are built - // earlier (above LaunchAgent, which needs the recall port). A single - // FsMemoryStore takes the project root per call (like the skill store), so - // one instance serves every open project. The mutating use cases share the - // event bus to announce Memory{Saved,Deleted}. - let create_memory = Arc::new(CreateMemory::new( - Arc::clone(&memory_store_port), - Arc::clone(&events_port), - )); - let update_memory = Arc::new(UpdateMemory::new( - Arc::clone(&memory_store_port), - Arc::clone(&events_port), - )); - let list_memories = Arc::new(ListMemories::new(Arc::clone(&memory_store_port))); - let get_memory = Arc::new(GetMemory::new(Arc::clone(&memory_store_port))); - let delete_memory = Arc::new(DeleteMemory::new( - Arc::clone(&memory_store_port), - Arc::clone(&events_port), - )); - let read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port))); - let resolve_memory_links = - Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port))); - // Naïve recall (LOT B): the same instance injected into LaunchAgent above — - // composes the store, returns index entries in order truncated to the token - // budget. The default, dependency-free MemoryRecall; substitutable by a - // VectorMemoryRecall (LOT C). - let recall_memory = Arc::new(RecallMemory::new(Arc::clone(&memory_recall_port))); - - // --- Orchestrator service (§14.3) --- - // Dispatches file-based orchestrator requests through the *same* use cases - // the UI drives (IdeA stays the single source of truth for the agent/skill - // lifecycle). The per-project watcher that feeds it is started lazily when - // a project is opened (see `ensure_orchestrator_watch`). - // File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche - // déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par - // tous les projets (clé interne = AgentId, jamais de collision cross-projet). - // Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de - // corrélation par ticket) + porte la **livraison** du tour dans le PTY de la - // cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est - // partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur. - let inmemory_mailbox = Arc::new(InMemoryMailbox::new()); - let mailbox = Arc::clone(&inmemory_mailbox) as Arc; - // Same concrete mailbox, second (read-only) port view for the work-state - // read model: lists pending tickets without touching the mutating surface. - let queue_snapshot = - Arc::clone(&inmemory_mailbox) as Arc; - let mediated_inbox = Arc::new( - MediatedInbox::with_pty( - Arc::clone(&inmemory_mailbox), - Arc::new(SystemMillisClock), - Arc::clone(&pty_port), - ) - // Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un - // tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4). - .with_events(Arc::clone(&events_port)), - ); - // Détection de stagnation (lot 2, readiness/heartbeat) : une tâche périodique - // détenue au composition root appelle `sweep_stalled` (logique de décision pure, - // `now` fourni par l'horloge injectée). Bascule `Alive→Stalled` les agents `Busy` - // sans battement depuis `stall_after_ms` (profil) et émet `AgentLivenessChanged` - // une fois par transition. Tick d'1 s : largement assez fin pour des seuils en - // dizaines de secondes, négligeable en charge. Détaché ⇒ vit autant que l'app. - { - let sweeper = Arc::clone(&mediated_inbox); - // `build` runs in Tauri's `setup` hook (main thread, *no* ambient Tokio - // runtime) — `tokio::spawn` would panic with "there is no reactor running". - // Use Tauri's global async runtime, like `events::spawn_relay` does. - tauri::async_runtime::spawn(async move { - let mut tick = tokio::time::interval(std::time::Duration::from_secs(1)); - loop { - tick.tick().await; - sweeper.sweep_stalled(); - } - }); - } - let input_mediator = Arc::clone(&mediated_inbox) as Arc; - let background_tasks = Arc::new(AppBackgroundTaskStore::new(Arc::clone(&store_port))); - let background_tasks_port = Arc::clone(&background_tasks) as Arc; - let (background_ready_tx, mut background_ready_rx) = - tokio::sync::mpsc::unbounded_channel::(); - - // --- B8 : runner de commandes + fermeture de la boucle du sink --- - // Le runner concret exécute les tâches command-backed sur le `PtyPort` composé - // (local ici ; SSH/WSL via `RemoteHost` quand ils atterriront — Liskov). Il - // émet UNE complétion par tâche ; le sink (single-writer) persiste l'état - // terminal AVANT de signaler la livraison (persist-avant-signal), puis le pont - // ready→inbox ci-dessous réveille l'agent propriétaire. - let background_runner = Arc::new(CommandBackgroundRunner::new( - Arc::clone(&pty_port), - Arc::clone(&clock) as Arc, - )); - let background_runner_port = - Arc::clone(&background_runner) as Arc; - { - let sink = Arc::new(BackgroundCompletionSink::new( - Arc::clone(&background_tasks_port), - background_ready_tx.clone(), - )); - let runner = Arc::clone(&background_runner_port); - // `start_from_runner` exige un runtime Tokio ambiant (`Handle::current`) : - // on l'appelle donc DANS la tâche async. Le `JoinHandle` du drain est gardé - // vivant en l'attendant (il ne se termine qu'à la fermeture du flux de - // complétions), ce qui maintient sink + runner en vie pour la session. - tauri::async_runtime::spawn(async move { - let drain = sink.start_from_runner(runner); - let _ = drain.await; - }); - } - let spawn_background_command = Arc::new(SpawnBackgroundCommand::new( - Arc::clone(&background_tasks_port), - Arc::clone(&background_runner_port), - Arc::clone(&clock) as Arc, - Arc::clone(&ids) as Arc, - )); - let cancel_background_task = Arc::new(CancelBackgroundTask::new( - Arc::clone(&background_tasks_port), - Arc::clone(&background_runner_port), - )); - let retry_background_task = Arc::new(RetryBackgroundTask::new( - Arc::clone(&background_tasks_port), - Arc::clone(&background_runner) as Arc, - Arc::clone(&spawn_background_command), - )); - let background_wake = Arc::new(AgentWakeService::new( - Arc::clone(&mediated_inbox) as Arc, - Arc::clone(&input_mediator), - Arc::clone(&mailbox), - Arc::clone(&background_tasks_port), - Arc::new(AppWakeSessionProvider { - launch_agent: Arc::clone(&orchestrator_launch_agent), - structured_sessions: Arc::clone(&structured_sessions), - }), - Some(Arc::clone(&events_port)), - )) as Arc; - { - let inbox = Arc::clone(&mediated_inbox); - let wake = Arc::clone(&background_wake); - let projects = Arc::clone(&store_port); - let clock_for_items = Arc::clone(&clock) as Arc; - let retry_ready = background_ready_tx.clone(); - tauri::async_runtime::spawn(async move { - while let Some(ready) = background_ready_rx.recv().await { - let item = InboxItem { - id: TicketId::new_random(), - agent_id: ready.owner_agent_id, - source: InboxSource::BackgroundTask { - task_id: ready.task_id, - }, - kind: InboxItemKind::BackgroundCompletion, - body: format!("Background task {} completed.", ready.task_id), - created_at_ms: clock_for_items.now_millis().max(0) as u64, - correlation_id: Some(ready.task_id.to_string()), - }; - match inbox.enqueue_message(ready.owner_agent_id, item) { - Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => { - application::diag!( - "[background-task] completion deferred: task={} owner={} \ - queue_depth={}", - ready.task_id, - ready.owner_agent_id, - receipt.depth - ); - schedule_background_ready_retry(retry_ready.clone(), ready); - continue; - } - Ok(_) => {} - Err(InboxError::InboxFull { .. }) => { - application::diag!( - "[background-task] completion inbox full but durable: task={} \ - owner={}", - ready.task_id, - ready.owner_agent_id - ); - schedule_background_ready_retry(retry_ready.clone(), ready); - continue; - } - Err(err) => { - application::diag!( - "[background-task] completion inbox enqueue failed: task={} \ - owner={} err={err}", - ready.task_id, - ready.owner_agent_id - ); - continue; - } - } - - let project = match projects.load_project(ready.project_id).await { - Ok(project) => project, - Err(err) => { - application::diag!( - "[background-task] completion wake skipped: project={} task={} \ - owner={} err={err}", - ready.project_id, - ready.task_id, - ready.owner_agent_id - ); - continue; - } - }; - if let Err(err) = wake - .wake_agent( - &project, - ready.owner_agent_id, - WakeReason::BackgroundCompletion { - task_id: ready.task_id, - }, - ) - .await - { - if matches!(err, WakeError::AgentBusy { .. }) { - application::diag!( - "[background-task] completion wake postponed: task={} owner={}", - ready.task_id, - ready.owner_agent_id - ); - schedule_background_wake_retry( - Arc::clone(&wake), - project, - ready.owner_agent_id, - ready.task_id, - ); - continue; - } - application::diag!( - "[background-task] completion wake failed: task={} owner={} err={err}", - ready.task_id, - ready.owner_agent_id - ); - } - } - }); - } - let live_sessions = Arc::new(LiveSessions::new( - Arc::clone(&terminal_sessions), - Arc::clone(&structured_sessions), - )); - // Réconciliation du live-state au reboot : repasse en `idle` les lignes - // fantômes (working/waiting/blocked) dont la session n'est plus vivante, - // selon le MÊME registre de liveness que `GetProjectWorkState`. Provider - // par root (le store fixe sa racine à la construction). Câblé dans - // `open_project`, best-effort (cf. `reconcile_layouts`). - let reconcile_live_state = Arc::new(AppReconcileLiveState { - projects: Arc::clone(&store_port), - registry: Arc::clone(&live_sessions), - clock: Arc::clone(&clock) as Arc, - }); - let reconcile_background_tasks = Arc::new(AppReconcileBackgroundTasks { - projects: Arc::clone(&store_port), - store: Arc::clone(&background_tasks), - clock: Arc::clone(&clock) as Arc, - ready: background_ready_tx, - }); - // Lot C — résumés de conversation best-effort : on câble les sources par - // project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule, - // aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets. - let get_project_work_state = Arc::new( - GetProjectWorkState::new( - Arc::clone(&contexts_port), - Arc::clone(&live_sessions), - Arc::clone(&input_mediator), - queue_snapshot, - ) - .with_conversation_sources( - Arc::new(AppHandoffProvider) as Arc, - Arc::new(AppConversationLogProvider) - as Arc, - ) - .with_background_tasks(Arc::clone(&background_tasks_port)), - ); - // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux - // composent le provider d'archive par root ; la rotation lit aussi le handoff - // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`. - let archive_provider = Arc::new(AppConversationArchiveProvider) - as Arc; - let read_conversation_page = - Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider))); - let rotate_conversation_log = Arc::new(RotateConversationLog::new( - Arc::clone(&archive_provider), - Arc::new(AppHandoffProvider) as Arc, - )); - // Lot D — actions contrôlées agent-level sur les sessions vivantes : attach - // (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive - // `CloseTerminal` existante / shutdown structuré). Aucune création de session. - let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions))); - - // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- - // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : - // l'horloge système, le bus partagé, un `TokioScheduler` (minuterie one-shot - // annulable) dont les tâches échues sont drainées plus bas, et un `AppAgentResumer` - // qui relance via le *même* `LaunchAgent` que la commande `launch_agent`. - let resume_contexts: ResumeContexts = Arc::new(Mutex::new(HashMap::new())); - let (resume_tx, mut resume_rx) = tokio::sync::mpsc::unbounded_channel::(); - let scheduler = Arc::new(TokioScheduler::new( - resume_tx, - Arc::clone(&clock) as Arc, - )) as Arc; - let resumer = Arc::new(AppAgentResumer { - launch_agent: Arc::clone(&launch_agent), - input_mediator: Arc::clone(&input_mediator), - contexts: Arc::clone(&resume_contexts), - }) as Arc; - let session_limit_service = Arc::new(SessionLimitService::new( - Arc::clone(&clock) as Arc, - scheduler, - Arc::clone(&events_port), - resumer, - )); - // Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une - // `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed). - // Patron `sweep_stalled` : `tauri::async_runtime::spawn` (pas `tokio::spawn` — `build` - // tourne dans le hook `setup` sans runtime ambiant). Détaché ⇒ vit autant que l'app. - { - let service = Arc::clone(&session_limit_service); - tauri::async_runtime::spawn(async move { - while let Some(task) = resume_rx.recv().await { - if let Err(e) = service.execute_resume(task).await { - // Best-effort : une relance qui échoue ne fige pas le drain. - eprintln!("[session-limit] reprise auto échouée : {e}"); - } - } - }); - } - - // Registre des conversations par paire (cadrage C3) : un fil par paire, session - // vivante keyée par conversation (lève l'ambiguïté session/agent). - let conversation_registry = Arc::new(InMemoryConversationRegistry::new()) - as Arc; - // Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en - // `Arc` puis cloné dans les quatre use cases, pour que les leases - // se coordonnent entre eux et que l'invariant single-writer du contexte projet tienne. - let file_guard = Arc::new(RwFileGuard::new()) as Arc; - let context_guard = Arc::new(ContextGuardUseCases { - read_context: Arc::new(ReadContext::new( - Arc::clone(&file_guard), - Arc::clone(&contexts_port), - Arc::clone(&fs_port), - )), - propose_context: Arc::new(ProposeContext::new( - Arc::clone(&file_guard), - Arc::clone(&contexts_port), - Arc::clone(&fs_port), - Arc::clone(&clock) as Arc, - )), - read_memory: Arc::new(ReadMemory::new( - Arc::clone(&file_guard), - Arc::clone(&memory_store_port), - )), - write_memory: Arc::new(WriteMemory::new( - Arc::clone(&file_guard), - Arc::clone(&memory_store_port), - )), - }); - let orchestrator_service = Arc::new( - OrchestratorService::new( - Arc::clone(&create_agent), - Arc::clone(&orchestrator_launch_agent), - Arc::clone(&list_agents), - Arc::clone(&close_terminal), - Arc::clone(&update_agent_context), - Arc::clone(&create_skill), - Arc::clone(&profile_store_port), - Arc::clone(&terminal_sessions), - ) - // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison - // PTV sérialisée) + registre de conversations + bus pour AgentReplied. - .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox)) - .with_conversations(Arc::clone(&conversation_registry)) - .with_events(Arc::clone(&events_port)) - // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour - // que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP - // réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip. - .with_mcp_runtime_provider( - Arc::new(AppMcpRuntimeProvider) as Arc - ) - // Persistance conversationnelle best-effort (lot P6b) : Prompt + Response de - // chaque paire déléguée écrits dans `/.ideai/conversations/` via - // le provider per-root + l'horloge millis (port Clock) déjà câblée. Un échec - // n'affecte jamais la délégation live. - .with_record_turn( - Arc::new(AppRecordTurnProvider) as Arc, - Arc::clone(&clock) as Arc, - ) - // FileGuard context/memory (cadrage C7) : branche les quatre use cases - // ReadContext/ProposeContext/ReadMemory/WriteMemory derrière le garde partagé. - // Sans ça, `require_context_guard()` reste `None` ⇒ les outils MCP - // `idea_context_*`/`idea_memory_*` échouent pour tous les agents. - .with_context_guard(context_guard) - // Auto-memory harvest (Lot E1) : après le checkpoint Response d'un `ask` - // réussi, parse les blocs ` ```idea-memory ` de la réponse et persiste les - // notes valides via le `MemoryStore` (root par appel). Best-effort strict : - // un échec n'altère jamais la réponse/le ticket/le handoff. - .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( - Arc::clone(&memory_store_port), - Arc::clone(&events_port), - ))) - // Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça, - // `skill.read` renverrait « not configured ». Compose le SkillStore existant. - .with_read_skill(Arc::clone(&read_skill)) - // Auto-update du live-state (programme live-state, lot LS3) : dérivé des - // transitions de délégation (ask→Working, reply→Done), zéro token agent. - // Provider par root (`/.ideai/live-state.json`) car le port fixe sa - // racine à la construction. Best-effort strict : un échec n'altère jamais la - // délégation. - .with_live_state(Arc::new(AppLiveStateProvider { - clock: Arc::clone(&clock) as Arc, - }) as Arc) - // Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot - // lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe - // sa racine à la construction). Sans ça, l'outil renverrait « not configured ». - .with_live_state_read(Arc::new(AppLiveStateLeanProvider { - clock: Arc::clone(&clock) as Arc, - }) as Arc) - // Fenêtre d'inactivité réarmable (signe de vie) du rendez-vous délégué : la - // borne de tour dans le service n'est plus un timeout plat mais une fenêtre - // réarmée à chaque progrès observé de la cible (octets cumulés de son - // transcript Claude), sous le plafond ci-dessous. SANS cette sonde, la borne - // dégrade vers un timeout plat (fallback, zéro régression) et coupe un long - // tour unique à 600 s — c'est précisément la sonde qui rend le fix effectif. - // Keyée par (project_root, agent_id) : le run-dir transcript = `/.ideai/ - // run/`, encodé par Claude sous `/.claude/projects/...`. - .with_ask_liveness_probe({ - let fs = Arc::clone(&fs_port); - let home = home_dir.clone(); - Arc::new(move |root: domain::project::ProjectPath, agent_id| { - let fs = Arc::clone(&fs); - let home = home.clone(); - Box::pin(async move { - let run_dir = format!( - "{}/.ideai/run/{agent_id}", - root.as_str().trim_end_matches(['/', '\\']) - ); - let cwd = domain::project::ProjectPath::new(run_dir).ok()?; - infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await - }) - as std::pin::Pin> + Send>> - }) as application::AskLivenessProbe - }) - // Plafond absolu du rendez-vous délégué (réglage projet via - // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : la fenêtre réarmée ne parque - // jamais un `ask` au-delà, même contre une cible perpétuellement active. - .with_ask_ceiling(application::resolve_rendezvous_ceiling( - std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") - .ok() - .and_then(|v| v.trim().parse::().ok()), - )) - // Conversation inter-agent headless : le service voit le même registre que le - // launcher orchestrateur ci-dessus. Une cible à `structured_adapter` est donc - // démarrée/drainée via `AgentSession::send` et son `Final`, sans dépendre de - // `idea_reply`; les autres outils MCP restent câblés par ailleurs. - .with_structured(Arc::clone(&structured_sessions)) - // Tâches de fond de 1re classe (B6/B7) : le port injecté est global mais - // route chaque opération vers un `FsBackgroundTaskStore` lié au root du - // projet concerné. Active notamment le rendez-vous headless tracé comme - // BackgroundTask, sans figer un store unique sur un root arbitraire. - .with_background_tasks( - Arc::clone(&background_tasks_port), - Arc::clone(&clock) as Arc, - ) - // Limites de session sur le chemin délégué headless : quand le drain - // structuré retourne `RateLimited`, l'orchestrateur arme la reprise pour - // la cible limitée via le même service que les chemins directs. - .with_session_limits(Arc::clone(&session_limit_service)) - // Producteur B8 côté agent MCP : `idea_run_in_background` passe par le - // même use case que la commande Tauri, sans aller-retour frontend. - .with_spawn_background_command(Arc::clone(&spawn_background_command)), - ); - openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( - Arc::clone(&orchestrator_service), - Arc::clone(&store_port), - )) as Arc); - - let stop_live_agent = Arc::new( - StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) - .with_cascade( - Arc::clone(&input_mediator), - Arc::clone(&orchestrator_service), - ), - ); - - // --- Windows (L10) --- - let move_tab = Arc::new(MoveTabToNewWindow::new( - Arc::clone(&store_port), - Arc::clone(&ids) as Arc, - )); - let snapshot_open_windows = - Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port))); - let restore_open_windows = Arc::new(RestoreOpenWindows::new( - Arc::clone(&window_state_port), - Arc::clone(&store_port), - )); + let core = Arc::new(BackendCore::build(app_data_dir)); + core.ticket_tool_binder + .bind(Arc::new(AppTicketToolProvider { + create: Arc::clone(&core.create_issue), + read: Arc::clone(&core.read_issue), + list: Arc::clone(&core.list_issues), + update: Arc::clone(&core.update_issue), + read_carnet: Arc::clone(&core.read_issue_carnet), + update_carnet: Arc::clone(&core.update_issue_carnet), + link: Arc::clone(&core.link_issues), + unlink: Arc::clone(&core.unlink_issues), + list_sprints: Arc::clone(&core.list_sprints), + }) as Arc); Self { - health, - create_project, - open_project, - close_project, - close_tab, - list_projects, - read_project_context, - update_project_context, - open_terminal, - write_terminal, - resize_terminal, - close_terminal, - load_layout, - mutate_layout, - list_layouts, - create_layout, - rename_layout, - delete_layout, - set_active_layout, - snapshot_running_agents, - reconcile_layouts, - reconcile_live_state, - reconcile_background_tasks, - detect_profiles, - list_profiles, - save_profile, - clone_opencode_profile_from_seed, - delete_profile, - configure_profiles, - reference_profiles, - first_run_state, - ensure_local_model_server, - list_model_servers, - save_model_server, - delete_model_server, - pty_port, - terminal_sessions, - event_bus, - create_issue, - read_issue, - delete_issue, - list_issues, - update_issue, - read_issue_carnet, - update_issue_carnet, - link_issues, - unlink_issues, - assign_issue_agent, - create_sprint, - list_sprints, - rename_sprint, - reorder_sprints, - delete_sprint, - assign_ticket_to_sprint, - unassign_ticket_from_sprint, - ticket_tool_provider, - open_ticket_assistant, - close_ticket_assistant, - tool_policy_registry, - pty_bridge, - structured_sessions, - chat_bridge, - create_agent, - list_agents, - read_agent_context, - update_agent_context, - delete_agent, - launch_agent, - change_agent_profile, - list_resumable_agents, - get_project_work_state, - read_conversation_page, - rotate_conversation_log, - attach_live_agent, - stop_live_agent, - inspect_conversation, - project_store, - get_project_permissions, - update_project_permissions, - update_agent_permissions, - resolve_agent_permissions, - create_template, - update_template, - list_templates, - delete_template, - create_agent_from_template, - detect_agent_drift, - sync_agent_with_template, - git_status, - git_stage, - git_unstage, - git_commit, - git_branches, - git_checkout, - git_log, - git_init, - git_graph, - create_skill, - update_skill, - list_skills, - delete_skill, - assign_skill, - unassign_skill, - create_memory, - update_memory, - list_memories, - get_memory, - delete_memory, - read_memory_index, - resolve_memory_links, - recall_memory, - list_embedder_profiles, - save_embedder_profile, - delete_embedder_profile, - describe_embedder_engines, - dismiss_embedder_suggestion, - orchestrator_service, - orchestrator_watchers: Mutex::new(HashMap::new()), - mcp_servers: Mutex::new(HashMap::new()), - session_limit_service, - resume_contexts, - turn_watcher, - turn_watch_input: Arc::clone(&input_mediator), - turn_watch_handles: Mutex::new(HashMap::new()), - fs_port: Arc::clone(&fs_port), - home_dir, - move_tab, - snapshot_open_windows, - restore_open_windows, + core, + pty_bridge: Arc::new(PtyBridge::new()), + chat_bridge: Arc::new(ChatBridge::new()), focused_project: Mutex::new(None), - spawn_background_command, - cancel_background_task, - retry_background_task, - background_task_store: Arc::clone(&background_tasks_port), } } @@ -2429,3945 +86,12 @@ impl AppState { .expect("focused project mutex poisoned") .clone() } - - /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already - /// running for it (idempotent). The watcher tails the project's - /// `.ideai/requests/` tree and dispatches each request through the shared - /// [`OrchestratorService`]; processed-request events are republished on the - /// domain event bus so the relay surfaces them to the frontend. - /// - /// Called from the `open_project` / `create_project` commands. Must run inside - /// the Tokio runtime (the watcher spawns a background task) — Tauri async - /// commands satisfy this. - pub fn ensure_orchestrator_watch(&self, project: &Project) { - let mut watchers = self - .orchestrator_watchers - .lock() - .expect("orchestrator watcher registry poisoned"); - if watchers.contains_key(&project.id) { - return; - } - let bus = Arc::clone(&self.event_bus); - let events: Arc = - Arc::new(move |event| bus.publish(event)); - let handle = FsOrchestratorWatcher::start( - project.clone(), - Arc::clone(&self.orchestrator_service), - events, - ); - watchers.insert(project.id, handle); - drop(watchers); - - // Start the MCP server for this project, beside (and in parallel with) the - // file watcher — both are entry doors onto the *same* OrchestratorService - // (Décision 4). Idempotent like the watcher above. - self.ensure_mcp_server(project); - } - - /// Starts an [`McpServer`] for `project` if one is not already registered - /// (idempotent), the **twin** of [`ensure_orchestrator_watch`]. Registers its - /// lifecycle handle in [`mcp_servers`](Self::mcp_servers); dropping/stopping the - /// handle tears down the supervision task. - /// - /// ## Transport decision (S-MCP, cadrage v5 §1) & endpoint lifecycle (M5a) - /// - /// At project-open time there is **no CLI connected yet**: a peer only appears - /// once an MCP-capable agent is launched with the injected MCP config (M5d) and - /// its spawned `idea mcp-server` bridge dials this project's loopback endpoint. - /// We therefore do **not** run a blocking `McpServer::serve`/accept loop here - /// (that must never figer the open/close of a project). M5a's job is narrower: - /// **bind the project's loopback endpoint** ([`mcp_endpoint`], the single source - /// of truth) so it is ready when a bridge connects, and **hold** the listener in - /// the handle. The actual accept-and-serve-per-peer is M5b/M5c. - /// - /// Binding is **non-blocking** (create the listener, then park on the stop - /// signal) and **idempotent** (one endpoint per project: a second open returns - /// early without rebinding). On close the handle is dropped, which on Unix - /// unlinks the socket file (interprocess reclaim guard) — no leak. - fn ensure_mcp_server(&self, project: &Project) { - let mut servers = self - .mcp_servers - .lock() - .expect("mcp server registry poisoned"); - if servers.contains_key(&project.id) { - return; - } - let bus = Arc::clone(&self.event_bus); - let events: Arc = - Arc::new(move |event| bus.publish(event)); - let endpoint = mcp_endpoint(&project.id); - let listener = bind_endpoint(&endpoint); - // The project-id string the handshake guard compares against: the same - // hyphen-free hex form the endpoint encodes, which M5d's `--project` reuses. - let project_id = project.id.as_uuid().simple().to_string(); - // Readiness de démarrage : quand le pont MCP d'un agent se connecte (initialize), - // libère son éventuel 1er tour différé (fix race cold-launch via signal MCP). - // L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la - // composition root est la seule à connaître la frontière infra↔domaine). - let service_for_ready = Arc::clone(&self.orchestrator_service); - let ready_sink: Arc = Arc::new(move |requester: &str| { - if let Ok(uuid) = Uuid::parse_str(requester) { - service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); - } - }); - let handle = McpServerHandle::start( - McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) - .with_events(events) - .with_ready_sink(ready_sink) - .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) - .with_tool_policies(Arc::clone(&self.tool_policy_registry)), - endpoint, - listener, - project_id, - ); - servers.insert(project.id, handle); - } - - /// Returns the ids of every currently-open project. - /// - /// Derived from the orchestrator watcher registry, which holds exactly one - /// entry per open project (started on open/create, dropped on close). Used by - /// the shutdown hook to snapshot running agents across all open projects - /// before the global PTY kill. - #[must_use] - pub fn open_project_ids(&self) -> Vec { - self.orchestrator_watchers - .lock() - .map(|w| w.keys().copied().collect()) - .unwrap_or_default() - } - - /// Repairs the persisted Claude run-dir artefacts of `project` so a freshly - /// launched AppImage does not inherit stale MCP/settings state from older runs. - /// - /// Best-effort and local-only: remote SSH/WSL projects are skipped because this - /// migration rewrites the local run-dir files the AppImage owns. Launch-time - /// repair still remains available on the normal activation path. - pub async fn reconcile_claude_run_dirs(&self, project: &Project) { - self.migrate_claude_run_dirs(project).await; - } - - /// Arms the **end-of-turn watcher** (no-reply backstop) for `agent_id` if its - /// `profile` is supported (Claude). The watcher tails the agent's isolated run-dir - /// transcript folder (`/.ideai/run/`) and routes each detected turn end - /// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called - /// at launch; **idempotent by replacement** — a relaunch drops the previous handle - /// (stopping its polling task) and arms a fresh one. A non-supported profile is a - /// no-op. `conversation_id` is diagnostic only. - pub fn arm_turn_watch( - &self, - project_root: &domain::project::ProjectPath, - agent_id: AgentId, - profile: &AgentProfile, - conversation_id: Option, - ) { - if !self.turn_watcher.supports(profile) { - return; - } - // cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that - // Claude runs in, hence the `/.claude/projects//` folder). - let run_dir = format!( - "{}/.ideai/run/{agent_id}", - project_root.as_str().trim_end_matches(['/', '\\']) - ); - let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else { - return; - }; - let input = Arc::clone(&self.turn_watch_input); - // Callback invoked from the watcher's polling task (no lock held here). - let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a)); - let handle = self - .turn_watcher - .watch(agent_id, conversation_id, cwd, on_turn_end); - if let Ok(mut map) = self.turn_watch_handles.lock() { - // Insert replaces (and drops) any prior handle ⇒ its polling task stops. - map.insert(agent_id, handle); - } - } - - /// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping - /// the stored handle stops its polling task. No-op if none is armed. - pub fn stop_turn_watch(&self, agent_id: AgentId) { - if let Ok(mut map) = self.turn_watch_handles.lock() { - map.remove(&agent_id); - } - } - - /// Stops and removes the orchestrator watcher for `project_id`, if any. - /// Called from `close_project` so a closed project stops consuming requests. - /// Symmetrically stops the project's MCP server (its twin) so both entry doors - /// are torn down together. - pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) { - if let Some(handle) = self - .orchestrator_watchers - .lock() - .expect("orchestrator watcher registry poisoned") - .remove(project_id) - { - handle.stop(); - } - if let Some(handle) = self - .mcp_servers - .lock() - .expect("mcp server registry poisoned") - .remove(project_id) - { - handle.stop(); - } - } - - /// Best-effort migration of persisted Claude run dirs at project-open time: - /// repairs stale `.mcp.json` declarations and missing - /// `enabledMcpjsonServers` entries in `.claude/settings.local.json`. - pub async fn migrate_claude_run_dirs(&self, project: &Project) { - if project.remote.kind() != RemoteKind::Local { - return; - } - let agents = match self - .list_agents - .execute(ListAgentsInput { - project: project.clone(), - }) - .await - { - Ok(output) => output.agents, - Err(_) => return, - }; - let profiles = match self.list_profiles.execute().await { - Ok(output) => output.profiles, - Err(_) => return, - }; - let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect(); - for agent in agents { - let Some(profile) = profile_by_id.get(&agent.profile_id) else { - continue; - }; - // Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex - // (`config.toml` isolé via `CODEX_HOME`). Tout autre profil ⇒ rien à migrer. - if is_claude_mcp_profile(profile) { - let _ = migrate_claude_run_dir(project, &agent.id, profile).await; - } else if is_codex_mcp_profile(profile) { - let _ = migrate_codex_run_dir(project, &agent.id, profile).await; - } - } - } } -async fn migrate_claude_run_dir( - project: &Project, - agent_id: &AgentId, - profile: &AgentProfile, -) -> Result<(), std::io::Error> { - let run_dir = project - .root - .as_str() - .trim_end_matches(['/', '\\']) - .to_owned() - + &format!("/.ideai/run/{agent_id}"); - let run_dir_path = Path::new(&run_dir); - if tokio::fs::metadata(run_dir_path).await.is_err() { - return Ok(()); - } +impl Deref for AppState { + type Target = BackendCore; - migrate_claude_settings(run_dir_path, project.root.as_str()).await?; - - let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { - exe, - endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) - .as_cli_arg() - .to_owned(), - project_id: project.id.as_uuid().simple().to_string(), - requester: agent_id.to_string(), - }); - migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?; - Ok(()) -} - -async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> { - let claude_dir = run_dir.join(".claude"); - let settings_path = claude_dir.join("settings.local.json"); - let next = match tokio::fs::read_to_string(&settings_path).await { - Ok(existing) => merge_claude_settings_json(&existing, project_root), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Some(claude_settings_seed_value(project_root)) - } - Err(err) => return Err(err), - }; - let Some(next) = next else { - return Ok(()); - }; - tokio::fs::create_dir_all(&claude_dir).await?; - let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; - body.push('\n'); - write_atomically(&settings_path, &body) -} - -async fn migrate_claude_mcp_config( - run_dir: &Path, - profile: &AgentProfile, - runtime: Option<&McpRuntime>, -) -> Result<(), std::io::Error> { - let Some(target) = claude_mcp_config_target(profile) else { - return Ok(()); - }; - let Some(runtime) = runtime else { - return Ok(()); - }; - let mcp_path = run_dir.join(target); - let desired_server = mcp_server_entry(profile, Some(runtime)); - - let next = match tokio::fs::read_to_string(&mcp_path).await { - Ok(existing) => merge_mcp_json(&existing, desired_server), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Some(wrap_idea_mcp_server(desired_server)) - } - Err(err) => return Err(err), - }; - let Some(next) = next else { - return Ok(()); - }; - if let Some(parent) = mcp_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; - body.push('\n'); - write_atomically(&mcp_path, &body) -} - -fn is_claude_mcp_profile(profile: &AgentProfile) -> bool { - let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude) - || matches!( - &profile.context_injection, - ContextInjection::ConventionFile { target } - if target - .rsplit(['/', '\\']) - .next() - .unwrap_or(target) - .eq_ignore_ascii_case("CLAUDE.md") - ); - is_claude && claude_mcp_config_target(profile).is_some() -} - -fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> { - match profile.mcp.as_ref().map(|mcp| &mcp.config) { - Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()), - _ => None, - } -} - -/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé -/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP -/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage. -/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global). -/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer -/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le -/// projecteur Codex. Best-effort, idempotent. -async fn migrate_codex_run_dir( - project: &Project, - agent_id: &AgentId, - profile: &AgentProfile, -) -> Result<(), std::io::Error> { - let run_dir = project - .root - .as_str() - .trim_end_matches(['/', '\\']) - .to_owned() - + &format!("/.ideai/run/{agent_id}"); - let run_dir_path = Path::new(&run_dir); - if tokio::fs::metadata(run_dir_path).await.is_err() { - return Ok(()); - } - - let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { - exe, - endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) - .as_cli_arg() - .to_owned(), - project_id: project.id.as_uuid().simple().to_string(), - requester: agent_id.to_string(), - }); - migrate_codex_mcp_config( - run_dir_path, - project.root.as_str(), - profile, - runtime.as_ref(), - ) - .await -} - -async fn migrate_codex_mcp_config( - run_dir: &Path, - project_root: &str, - profile: &AgentProfile, - runtime: Option<&McpRuntime>, -) -> Result<(), std::io::Error> { - let Some((target, _home_env)) = codex_mcp_config_target(profile) else { - return Ok(()); - }; - // Sans runtime réel, seule une déclaration minimale est disponible : on ne - // régénère pas (mirror de `migrate_claude_mcp_config`). - let Some(runtime) = runtime else { - return Ok(()); - }; - let toml_path = run_dir.join(target); - let declaration = mcp_server_entry_toml(profile, Some(runtime)); - - // Ne jamais clobber le fichier complet ici : il porte aussi la projection de - // permissions Codex. On répare seulement la table MCP et les entrées trust. - let existing = match tokio::fs::read_to_string(&toml_path).await { - Ok(existing) => Some(existing), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, - Err(err) => return Err(err), - }; - let desired = codex_config_toml_for_migration( - existing.as_deref(), - &declaration, - run_dir.to_string_lossy().as_ref(), - project_root, - ); - if existing.as_deref() == Some(desired.as_str()) { - return Ok(()); - } - if let Some(parent) = toml_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - write_atomically(&toml_path, &desired) -} - -fn is_codex_mcp_profile(profile: &AgentProfile) -> bool { - let is_codex = profile.structured_adapter == Some(StructuredAdapter::Codex) - || matches!( - &profile.context_injection, - ContextInjection::ConventionFile { target } - if target - .rsplit(['/', '\\']) - .next() - .unwrap_or(target) - .eq_ignore_ascii_case("AGENTS.md") - ); - is_codex && codex_mcp_config_target(profile).is_some() -} - -fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> { - match profile.mcp.as_ref().map(|mcp| &mcp.config) { - Some(McpConfigStrategy::TomlConfigHome { target, home_env }) => { - Some((target.as_str(), home_env.as_str())) - } - _ => None, - } -} - -/// Rend le contenu du `config.toml` Codex (table `[mcp_servers.idea]`) en réutilisant -/// l'encodeur TOML partagé [`domain::McpServerWiring::to_config_toml`] (D2) — même -/// source de wiring que la déclaration `.mcp.json` Claude, donc zéro dérive. -fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> String { - let transport = profile - .mcp - .as_ref() - .map_or(McpTransport::Stdio, |m| m.transport); - let (command, args) = match runtime { - Some(rt) => ( - rt.exe.clone(), - vec![ - "mcp-server".to_owned(), - "--endpoint".to_owned(), - rt.endpoint.clone(), - "--project".to_owned(), - rt.project_id.clone(), - "--requester".to_owned(), - rt.requester.clone(), - ], - ), - None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), - }; - domain::McpServerWiring::new(command, args, transport).to_config_toml() -} - -fn codex_config_toml_for_migration( - existing: Option<&str>, - mcp_declaration: &str, - run_dir: &str, - project_root: &str, -) -> String { - let mut text = replace_toml_table_block( - existing.unwrap_or_default(), - "mcp_servers.idea", - mcp_declaration.trim_end(), - ); - text = ensure_codex_project_trust(&text, run_dir); - text = ensure_codex_project_trust(&text, project_root); - if !text.ends_with('\n') { - text.push('\n'); - } - text -} - -fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String { - let header = format!("[{table}]"); - let mut out = Vec::new(); - let mut skipping = false; - let mut inserted = false; - - for line in existing.lines() { - let trimmed = line.trim(); - if trimmed == header { - if !inserted { - push_toml_block(&mut out, replacement); - inserted = true; - } - skipping = true; - continue; - } - if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') { - skipping = false; - } - if !skipping { - out.push(line.to_owned()); - } - } - - if !inserted { - if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) { - out.push(String::new()); - } - push_toml_block(&mut out, replacement); - } - - out.join("\n") -} - -fn push_toml_block(out: &mut Vec, block: &str) { - out.extend(block.lines().map(ToOwned::to_owned)); -} - -fn ensure_codex_project_trust(existing: &str, path: &str) -> String { - if path.is_empty() { - return existing.to_owned(); - } - let header = format!(r#"[projects.{}]"#, toml_quoted(path)); - if existing.lines().any(|line| line.trim() == header) { - return existing.to_owned(); - } - let mut text = existing.trim_end().to_owned(); - if !text.is_empty() { - text.push_str("\n\n"); - } - text.push_str(&header); - text.push_str("\ntrust_level = \"trusted\"\n"); - text -} - -fn toml_quoted(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} - -fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option { - let mut doc = match serde_json::from_str::(existing) { - Ok(Value::Object(map)) => Value::Object(map), - Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)), - }; - let before = doc.clone(); - let root = doc.as_object_mut().expect("object preserved above"); - let permissions = root - .entry("permissions".to_owned()) - .or_insert_with(|| Value::Object(Map::new())); - if !permissions.is_object() { - *permissions = Value::Object(Map::new()); - } - let permissions = permissions - .as_object_mut() - .expect("permissions forced to object"); - permissions.insert( - "defaultMode".to_owned(), - Value::String("bypassPermissions".to_owned()), - ); - permissions.insert( - "additionalDirectories".to_owned(), - Value::Array(merge_string_array( - permissions.get("additionalDirectories"), - [project_root], - )), - ); - permissions.insert( - "allow".to_owned(), - Value::Array(merge_string_array( - permissions.get("allow"), - ["Read", "Edit", "Write", "Bash"], - )), - ); - permissions.insert( - "deny".to_owned(), - Value::Array(merge_string_array( - permissions.get("deny"), - [ - "Bash(sudo *)", - "Bash(rm -rf /)", - "Bash(rm -rf /*)", - "Bash(rm -rf ~)", - "Bash(rm -rf ~/)", - "Bash(rm -rf ~/*)", - "Bash(rm -rf $HOME*)", - "Bash(mkfs*)", - "Bash(dd if=*)", - "Bash(shutdown*)", - "Bash(reboot*)", - ], - )), - ); - root.insert( - "skipDangerousModePermissionPrompt".to_owned(), - Value::Bool(true), - ); - root.insert( - "enabledMcpjsonServers".to_owned(), - Value::Array(merge_string_array( - root.get("enabledMcpjsonServers"), - ["idea"], - )), - ); - let sandbox = root - .entry("sandbox".to_owned()) - .or_insert_with(|| Value::Object(Map::new())); - if !sandbox.is_object() { - *sandbox = Value::Object(Map::new()); - } - let sandbox = sandbox.as_object_mut().expect("sandbox forced to object"); - sandbox.insert("enabled".to_owned(), Value::Bool(false)); - - if doc != before { - Some(doc) - } else { - None - } -} - -fn merge_mcp_json(existing: &str, desired_server: Value) -> Option { - let mut doc = match serde_json::from_str::(existing) { - Ok(Value::Object(map)) => map, - Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)), - }; - let mcp_servers = doc - .entry("mcpServers".to_owned()) - .or_insert_with(|| Value::Object(Map::new())); - if !mcp_servers.is_object() { - *mcp_servers = Value::Object(Map::new()); - } - let changed = mcp_servers.get("idea") != Some(&desired_server); - if !changed { - return None; - } - match mcp_servers { - Value::Object(servers) => { - servers.insert("idea".to_owned(), desired_server); - Some(Value::Object(doc)) - } - _ => None, - } -} - -fn wrap_idea_mcp_server(server: Value) -> Value { - let mut servers = Map::new(); - servers.insert("idea".to_owned(), server); - let mut root = Map::new(); - root.insert("mcpServers".to_owned(), Value::Object(servers)); - Value::Object(root) -} - -fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value { - let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) { - Some(McpTransport::Socket) => "socket", - Some(McpTransport::Stdio) | None => "stdio", - }; - let (command, args) = match runtime { - Some(rt) => ( - rt.exe.clone(), - vec![ - Value::String("mcp-server".to_owned()), - Value::String("--endpoint".to_owned()), - Value::String(rt.endpoint.clone()), - Value::String("--project".to_owned()), - Value::String(rt.project_id.clone()), - Value::String("--requester".to_owned()), - Value::String(rt.requester.clone()), - ], - ), - None => ( - "idea".to_owned(), - vec![Value::String("mcp-server".to_owned())], - ), - }; - let mut server = Map::new(); - server.insert("command".to_owned(), Value::String(command)); - server.insert("args".to_owned(), Value::Array(args)); - server.insert("transport".to_owned(), Value::String(transport.to_owned())); - Value::Object(server) -} - -fn claude_settings_seed_value(project_root: &str) -> Value { - json!({ - "permissions": { - "defaultMode": "bypassPermissions", - "additionalDirectories": [project_root], - "allow": ["Read", "Edit", "Write", "Bash"], - "deny": [ - "Bash(sudo *)", - "Bash(rm -rf /)", - "Bash(rm -rf /*)", - "Bash(rm -rf ~)", - "Bash(rm -rf ~/)", - "Bash(rm -rf ~/*)", - "Bash(rm -rf $HOME*)", - "Bash(mkfs*)", - "Bash(dd if=*)", - "Bash(shutdown*)", - "Bash(reboot*)" - ] - }, - "skipDangerousModePermissionPrompt": true, - "enabledMcpjsonServers": ["idea"], - "sandbox": { "enabled": false } - }) -} - -fn merge_string_array<'a>( - existing: Option<&Value>, - required: impl IntoIterator, -) -> Vec { - let mut seen = HashSet::::new(); - let mut out = Vec::new(); - - if let Some(existing) = existing.and_then(Value::as_array) { - for item in existing.iter().filter_map(Value::as_str) { - if seen.insert(item.to_owned()) { - out.push(Value::String(item.to_owned())); - } - } - } - - for item in required { - if seen.insert(item.to_owned()) { - out.push(Value::String(item.to_owned())); - } - } - - out -} - -fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let file_name = path - .file_name() - .map(OsString::from) - .unwrap_or_else(|| OsString::from("tmp")); - let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4()); - let tmp_path = path.with_file_name(tmp_name); - - std::fs::write(&tmp_path, content.as_bytes())?; - #[cfg(windows)] - if path.exists() { - let _ = std::fs::remove_file(path); - } - std::fs::rename(&tmp_path, path)?; - Ok(()) -} - -#[cfg(test)] -mod run_dir_migration_tests { - use super::{ - claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry, - merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir, - }; - use application::McpRuntimeProvider; - use domain::ids::{AgentId, ProfileId, ProjectId}; - use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, - StructuredAdapter, - }; - use serde_json::json; - use uuid::Uuid; - - fn claude_profile() -> AgentProfile { - AgentProfile::new( - ProfileId::from_uuid(Uuid::from_u128(9)), - "Claude Code", - "claude", - Vec::new(), - ContextInjection::convention_file("CLAUDE.md").unwrap(), - None, - "{agentRunDir}", - None, - ) - .unwrap() - .with_structured_adapter(StructuredAdapter::Claude) - .with_mcp(McpCapability::new( - McpConfigStrategy::config_file(".mcp.json").unwrap(), - McpTransport::Stdio, - )) - } - - fn codex_profile() -> AgentProfile { - AgentProfile::new( - ProfileId::from_uuid(Uuid::from_u128(10)), - "OpenAI Codex CLI", - "codex", - Vec::new(), - ContextInjection::convention_file("AGENTS.md").unwrap(), - None, - "{agentRunDir}", - None, - ) - .unwrap() - .with_structured_adapter(StructuredAdapter::Codex) - .with_mcp(McpCapability::new( - McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(), - McpTransport::Stdio, - )) - } - - fn runtime(agent_id: AgentId) -> application::McpRuntime { - application::McpRuntime { - exe: "/opt/IdeA.AppImage".to_owned(), - endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), - project_id: ProjectId::from_uuid(Uuid::from_u128(1234)) - .as_uuid() - .simple() - .to_string(), - requester: agent_id.to_string(), - } - } - - #[test] - fn merge_claude_settings_adds_idea_and_preserves_existing_entries() { - let merged = merge_claude_settings_json( - r#"{ - "permissions": { - "additionalDirectories": ["/tmp/custom"], - "allow": ["Read"], - "deny": ["Bash(custom)"] - }, - "enabledMcpjsonServers": ["other"], - "extra": true -}"#, - "/home/me/proj", - ) - .unwrap(); - - let parsed = merged; - assert_eq!(parsed["extra"], json!(true)); - assert_eq!( - parsed["permissions"]["defaultMode"], - json!("bypassPermissions") - ); - assert!(parsed["permissions"]["additionalDirectories"] - .as_array() - .unwrap() - .contains(&json!("/tmp/custom"))); - assert!(parsed["permissions"]["additionalDirectories"] - .as_array() - .unwrap() - .contains(&json!("/home/me/proj"))); - assert!(parsed["enabledMcpjsonServers"] - .as_array() - .unwrap() - .contains(&json!("other"))); - assert!(parsed["enabledMcpjsonServers"] - .as_array() - .unwrap() - .contains(&json!("idea"))); - } - - #[test] - fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() { - let agent_id = AgentId::from_uuid(Uuid::from_u128(77)); - let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id))); - let merged = merge_mcp_json( - r#"{ - "mcpServers": { - "idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" }, - "other": { "command": "keep-me", "args": [] } - } -}"#, - desired.clone(), - ) - .unwrap(); - - let parsed = merged; - assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me")); - assert_eq!(parsed["mcpServers"]["idea"], desired); - } - - #[test] - fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() { - let agent_id = AgentId::from_uuid(Uuid::from_u128(88)); - let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4())); - let project_root = temp.join("project"); - let run_dir = project_root.join(".ideai/run").join(agent_id.to_string()); - std::fs::create_dir_all(run_dir.join(".claude")).unwrap(); - std::fs::write( - run_dir.join(".claude/settings.local.json"), - r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#, - ) - .unwrap(); - std::fs::write( - run_dir.join(".mcp.json"), - r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#, - ) - .unwrap(); - - let project = domain::Project::new( - ProjectId::from_uuid(Uuid::from_u128(1234)), - "demo", - domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), - domain::remote::RemoteRef::local(), - 1, - ) - .unwrap(); - let profile = claude_profile(); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - migrate_claude_run_dir(&project, &agent_id, &profile) - .await - .unwrap(); - }); - - let settings: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(), - ) - .unwrap(); - assert!(settings["enabledMcpjsonServers"] - .as_array() - .unwrap() - .contains(&json!("idea"))); - assert!(settings["permissions"]["additionalDirectories"] - .as_array() - .unwrap() - .contains(&json!(project.root.as_str()))); - - let mcp: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap()) - .unwrap(); - let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider - .runtime_for(&project, agent_id) - .unwrap(); - assert_eq!( - mcp["mcpServers"]["idea"], - mcp_server_entry(&claude_profile(), Some(&expected_runtime)) - ); - - let _ = std::fs::remove_dir_all(temp); - } - - #[test] - fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() { - let agent_id = AgentId::from_uuid(Uuid::from_u128(89)); - let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4())); - let project_root = temp.join("project"); - let run_dir = project_root.join(".ideai/run").join(agent_id.to_string()); - std::fs::create_dir_all(run_dir.join(".codex")).unwrap(); - std::fs::write( - run_dir.join(".codex/config.toml"), - r#"approval_policy = "never" -sandbox_mode = "workspace-write" - -[mcp_servers.idea] -command = "stale" -args = ["mcp-server"] -transport = "stdio" -"#, - ) - .unwrap(); - - let project = domain::Project::new( - ProjectId::from_uuid(Uuid::from_u128(1234)), - "demo", - domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), - domain::remote::RemoteRef::local(), - 1, - ) - .unwrap(); - let profile = codex_profile(); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - migrate_codex_run_dir(&project, &agent_id, &profile) - .await - .unwrap(); - }); - - let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap(); - assert!(config.contains(r#"approval_policy = "never""#)); - assert!(config.contains(r#"sandbox_mode = "workspace-write""#)); - assert!(config.contains("[mcp_servers.idea]")); - assert!(config.contains(r#"default_tools_approval_mode = "approve""#)); - assert!(config.contains("tool_timeout_sec = 86400")); - assert!(!config.contains(r#"command = "stale""#)); - assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy()))); - assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str()))); - - let _ = std::fs::remove_dir_all(temp); - } - - #[test] - fn claude_profile_guard_matches_only_claude_mcp_profiles() { - assert!(is_claude_mcp_profile(&claude_profile())); - } - - #[test] - fn invalid_settings_fall_back_to_seed() { - let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap(); - assert_eq!(merged, claude_settings_seed_value("/home/me/proj")); - } - - #[test] - fn malformed_typed_settings_are_repaired_without_panicking() { - let merged = merge_claude_settings_json( - r#"{ - "permissions": [], - "sandbox": false, - "enabledMcpjsonServers": "idea" -}"#, - "/home/me/proj", - ) - .unwrap(); - - assert_eq!( - merged["permissions"]["defaultMode"], - json!("bypassPermissions") - ); - assert_eq!(merged["sandbox"]["enabled"], json!(false)); - assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"])); - } -} - -/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the -/// bound [`LocalSocketListener`] or `None` if the bind fails (e.g. a stale socket -/// file from a crashed run). A `None` never figes open/close — the registry entry -/// is still created so the lifecycle stays idempotent; the real accept/serve (M5c) -/// will surface a hard failure if it actually needs the listener. -/// -/// On Unix this binds a filesystem-path UDS under the per-user runtime dir; the -/// parent directory is created if missing, and a corpse socket from a previous run -/// is replaced (`reclaim_name` so the file is unlinked on drop). On Windows it binds -/// the named pipe — no filesystem entry to manage. -#[must_use] -fn bind_endpoint(endpoint: &McpEndpoint) -> Option { - // Ensure the runtime dir exists (Unix path sockets need their parent dir). - if let Some(path) = endpoint.socket_path() { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - // D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding. - // `reclaim_name(true)` (below) only unlinks the socket on *drop*; in - // `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time, - // so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test - // proves this). We therefore unlink it ourselves first — but only when the - // path is actually a **socket** (never clobber a real file we don't own). - #[cfg(unix)] - { - use std::os::unix::fs::FileTypeExt; - if let Ok(meta) = std::fs::symlink_metadata(&path) { - if meta.file_type().is_socket() { - let _ = std::fs::remove_file(&path); - } - } - } - } - let name = endpoint.as_cli_arg().to_fs_name::().ok()?; - ListenerOptions::new() - .name(name) - // Reclaim (unlink) on drop so a clean close leaves no socket behind. - .reclaim_name(true) - .create_tokio() - .ok() -} - -/// Drives one accepted loopback peer (= one `idea mcp-server` bridge = one agent): -/// reads its **handshake line**, then serves the rest of the stream as JSON-RPC. -/// -/// Sequence (cadrage v5 §1.4, M5b handshake format): -/// 1. Split the duplex connection so reads and writes are independent. -/// 2. Read **one** newline-terminated handshake line -/// (`{"project":"…","requester":"…"}`) off a `BufReader` over the read half. -/// 3. If the handshake's `project` is present and **mismatches** this server's -/// project, close the connection (return) without serving — a defensive guard, it -/// logs nothing and never crashes the accept loop. -/// 4. Wrap the **same** `BufReader` (carrying any bytes already buffered past the -/// handshake) + the write half in a [`StdioTransport`] and hand it to -/// [`McpServer::serve_as`] tagged with the handshake's `requester` — so -/// `OrchestratorRequestProcessed.requester_id` becomes the real agent id. -/// -/// Generic over the stream type so it never names the `interprocess` connection -/// type and stays unit-testable over an in-memory duplex. -async fn serve_peer(server: Arc, expected_project: &str, conn: S) -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, -{ - let (read_half, write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - // 1 handshake line. A read error or immediate EOF ⇒ no peer to serve. - let mut handshake = String::new(); - match reader.read_line(&mut handshake).await { - Ok(0) | Err(_) => return, - Ok(_) => {} - } - - let (handshake_project, requester) = parse_handshake(&handshake); - - // Defensive: a bridge dialed the wrong project's endpoint. Drop it cleanly. - if !handshake_project.is_empty() - && !expected_project.is_empty() - && handshake_project != expected_project - { - return; - } - - // The same BufReader keeps any bytes already buffered past the handshake, so no - // JSON-RPC line is lost between the handshake and the serve loop. - let mut transport = StdioTransport::from_buffered(reader, write_half); - server.serve_as(requester, &mut transport).await; -} - -/// Parses the M5b handshake line into `(project, requester)`. Tolerant: a malformed -/// or partial line yields empty strings (⇒ legacy `"mcp"` requester, no project -/// guard), never an error — the serve loop must never crash on a bad handshake. -fn parse_handshake(line: &str) -> (String, String) { - serde_json::from_str::(line.trim()) - .ok() - .map(|v| { - let project = v - .get("project") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_owned(); - let requester = v - .get("requester") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_owned(); - (project, requester) - }) - .unwrap_or_default() -} - -/// Lifecycle handle for a per-project [`McpServer`] supervision task — the **twin** -/// of [`OrchestratorWatchHandle`](infrastructure::OrchestratorWatchHandle). -/// -/// It carries the **same** stop mechanism as the watcher (a one-shot -/// `mpsc::Sender<()>`): [`stop`](Self::stop) signals the task to exit, and dropping -/// the handle stops it too (the channel closes). -/// -/// ## Accept-and-serve-per-peer (M5c) -/// -/// The supervision task runs an **`accept` loop** on the bound loopback `listener`, -/// arbitrated against the stop signal by `tokio::select!`: -/// - **`accept`** is async and parks on the absence of a peer, so the loop never -/// figes the open/close of a project — at project-open time no CLI is connected -/// yet (a peer only appears once an MCP-capable agent is launched, M5d). -/// - **each accepted connection** = one `idea mcp-server` bridge = one agent. The -/// task reads the **handshake line** (`{"project","requester"}`, cadrage v5 §1.4), -/// then spawns an isolated task running [`McpServer::serve_as`] over the rest of -/// the stream, so one peer's disconnection/error never affects the others or the -/// accept loop. -/// - **stop** breaks the loop, **aborts** the in-flight serve tasks (`JoinSet`), -/// and drops the listener — which on Unix unlinks the socket file via -/// interprocess' reclaim guard, so closing a project leaves no socket behind. -pub struct McpServerHandle { - stop: tokio::sync::mpsc::Sender<()>, - /// The loopback address this server listens on — the single source of truth - /// ([`mcp_endpoint`]) shared with the CLI-declaration writer (M5d). - endpoint: McpEndpoint, -} - -impl McpServerHandle { - /// Spawns the supervision task that owns `server` and the bound `listener`, runs - /// the accept-and-serve-per-peer loop, and stops cleanly on signal. Must run - /// inside the ambient Tokio runtime (Tauri async commands satisfy this), exactly - /// like [`FsOrchestratorWatcher::start`](infrastructure::FsOrchestratorWatcher). - #[must_use] - fn start( - server: McpServer, - endpoint: McpEndpoint, - listener: Option, - project_id: String, - ) -> Self { - let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel::<()>(1); - // The base server is shared (Arc) so each accepted peer derives its own - // requester-tagged clone (McpServer::serve_as) without contending. - let server = Arc::new(server); - tokio::spawn(async move { - // No listener (bind failed, e.g. stale socket): nothing to accept. Park - // on stop so the lifecycle stays idempotent and non-blocking. - let Some(listener) = listener else { - let _ = stop_rx.recv().await; - return; - }; - // In-flight per-peer serve tasks; aborted en masse on stop so no serve - // outlives the project's close. - let mut serves: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); - loop { - tokio::select! { - // Stop requested (or the handle dropped, closing the channel). - _ = stop_rx.recv() => break, - accepted = listener.accept() => { - match accepted { - Ok(conn) => { - let server = Arc::clone(&server); - let expected_project = project_id.clone(); - serves.spawn(async move { - serve_peer(server, &expected_project, conn).await; - }); - } - // A transient accept error must not kill the loop; the - // listener stays bound and keeps accepting the next peer. - Err(_) => continue, - } - } - } - } - // Stop: terminate every in-flight serve, then drop the listener (unlinks - // the Unix socket file). Pending peers die with their owning CLI anyway. - serves.abort_all(); - drop(serves); - drop(listener); - }); - Self { - stop: stop_tx, - endpoint, - } - } - - /// The loopback endpoint this project's server is bound to (M5a source of truth). - #[must_use] - pub fn endpoint(&self) -> &McpEndpoint { - &self.endpoint - } - - /// Signals the supervision task to stop (best-effort; dropping the handle also - /// stops it). Mirrors [`OrchestratorWatchHandle::stop`](infrastructure::OrchestratorWatchHandle::stop). - pub fn stop(&self) { - let _ = self.stop.try_send(()); - } -} - -/// Build the project memory recall port from an embedder profile. -/// -/// This is the only place that knows the concrete recall adapters (DIP): callers -/// only ever see `Arc`. With the default `none` profile, -/// `embedder_from_profile` returns `None` and we hand back the plain -/// `NaiveMemoryRecall` — strictly identical, dependency-free behaviour. When an -/// embedder is configured, we wrap naïve + vector recall in an -/// `AdaptiveMemoryRecall` that switches stages live per the profile strategy. -pub(crate) fn build_memory_recall( - fs: Arc, - store: Arc, - profile: &EmbedderProfile, - onnx_cache_dir: &std::path::Path, -) -> Arc { - let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc; - match embedder_from_profile(profile, onnx_cache_dir) { - None => naive, - Some(embedder) => { - let embedder: Arc = Arc::from(embedder); - let vector: Arc = Arc::new(VectorMemoryRecall::new( - embedder, - Arc::clone(&store), - Arc::clone(&fs), - )); - Arc::new(AdaptiveMemoryRecall::new( - naive, - vector, - Arc::clone(&store), - profile.strategy, - )) - } - } -} - -#[cfg(test)] -mod background_tasks_b7_tests { - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::{Arc, Mutex}; - - use async_trait::async_trait; - use domain::ports::{BackgroundTaskStore, ProjectStore, StoreError}; - use domain::{ - AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, - BackgroundTaskWakePolicy, Project, ProjectId, ProjectPath, RemoteRef, TaskId, - }; - use tokio::sync::mpsc::unbounded_channel; - use uuid::Uuid; - - use super::{AppBackgroundTaskStore, AppReconcileBackgroundTasks}; - - struct TempDir(PathBuf); - - impl TempDir { - fn new() -> Self { - let path = std::env::temp_dir().join(format!("idea-b7-reconcile-{}", Uuid::new_v4())); - std::fs::create_dir_all(&path).unwrap(); - Self(path) - } - - fn project_path(&self) -> ProjectPath { - ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - #[derive(Default)] - struct FakeProjects { - projects: Mutex>, - } - - impl FakeProjects { - fn insert(&self, project: Project) { - self.projects.lock().unwrap().insert(project.id, project); - } - } - - #[async_trait] - impl ProjectStore for FakeProjects { - async fn list_projects(&self) -> Result, StoreError> { - Ok(self.projects.lock().unwrap().values().cloned().collect()) - } - - async fn load_project(&self, id: ProjectId) -> Result { - self.projects - .lock() - .unwrap() - .get(&id) - .cloned() - .ok_or(StoreError::NotFound) - } - - async fn save_project(&self, project: &Project) -> Result<(), StoreError> { - self.insert(project.clone()); - Ok(()) - } - - async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> { - Ok(()) - } - - async fn load_workspace(&self) -> Result { - Ok(domain::Workspace::default()) - } - } - - struct FixedClock(i64); - - impl domain::ports::Clock for FixedClock { - fn now_millis(&self) -> i64 { - self.0 - } - } - - fn project_id(n: u128) -> ProjectId { - ProjectId::from_uuid(Uuid::from_u128(n)) - } - - fn agent_id(n: u128) -> AgentId { - AgentId::from_uuid(Uuid::from_u128(n)) - } - - fn task_id(n: u128) -> TaskId { - TaskId::from_uuid(Uuid::from_u128(n)) - } - - fn project(id: ProjectId, root: ProjectPath) -> Project { - Project::new(id, "demo", root, RemoteRef::local(), 1_000).unwrap() - } - - fn completed_task( - id: u128, - project_id: ProjectId, - owner: AgentId, - wake_policy: BackgroundTaskWakePolicy, - ) -> BackgroundTask { - BackgroundTask::new( - task_id(id), - project_id, - owner, - BackgroundTaskKind::Command { - label: format!("task-{id}"), - }, - wake_policy, - 1_000, - None, - ) - .unwrap() - .transition(BackgroundTaskState::Running, 1_010) - .unwrap() - .complete(BackgroundTaskResult::Success { - finished_at_ms: 1_020, - exit_code: Some(0), - summary: "ok".to_owned(), - stdout_tail: None, - stderr_tail: None, - }) - .unwrap() - } - - fn running_task(id: u128, project_id: ProjectId, owner: AgentId) -> BackgroundTask { - BackgroundTask::new( - task_id(id), - project_id, - owner, - BackgroundTaskKind::Command { - label: format!("task-{id}"), - }, - BackgroundTaskWakePolicy::WakeOwner, - 1_000, - None, - ) - .unwrap() - .transition(BackgroundTaskState::Running, 1_010) - .unwrap() - } - - #[tokio::test] - async fn app_reconcile_requeues_wake_owner_marks_record_only_and_repairs_orphan_running() { - let tmp = TempDir::new(); - let project_id = project_id(10); - let owner = agent_id(100); - let projects = Arc::new(FakeProjects::default()); - projects.insert(project(project_id, tmp.project_path())); - - let store = Arc::new(AppBackgroundTaskStore::new( - Arc::clone(&projects) as Arc - )); - let wake_owner = completed_task(1, project_id, owner, BackgroundTaskWakePolicy::WakeOwner); - let record_only = - completed_task(2, project_id, owner, BackgroundTaskWakePolicy::RecordOnly); - let orphan = running_task(3, project_id, owner); - store.create(&wake_owner).await.unwrap(); - store.create(&record_only).await.unwrap(); - store.create(&orphan).await.unwrap(); - - let (ready_tx, mut ready_rx) = unbounded_channel(); - let reconcile = AppReconcileBackgroundTasks { - projects: Arc::clone(&projects) as Arc, - store: Arc::clone(&store), - clock: Arc::new(FixedClock(2_000)), - ready: ready_tx, - }; - - reconcile.execute(project_id).await.unwrap(); - - let mut ready = Vec::new(); - while let Ok(item) = ready_rx.try_recv() { - ready.push(item.task_id); - } - assert_eq!(ready, vec![wake_owner.id, orphan.id]); - - let wake_owner_after = store.get(wake_owner.id).await.unwrap().unwrap(); - assert!(!wake_owner_after.completion_delivered); - - let record_only_after = store.get(record_only.id).await.unwrap().unwrap(); - assert!(record_only_after.completion_delivered); - - let orphan_after = store.get(orphan.id).await.unwrap().unwrap(); - assert_eq!(orphan_after.state, BackgroundTaskState::Failed); - assert!(orphan_after.has_pending_completion_delivery()); - assert!(matches!( - orphan_after.result, - Some(BackgroundTaskResult::Failure { - finished_at_ms: 2_000, - .. - }) - )); - } -} - -#[cfg(test)] -mod mcp_serve_peer_tests { - //! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`]. - //! - //! These drive the **private** `serve_peer` free function over an in-memory - //! `tokio::io::duplex` (no socket, no child process), the test seam the prod - //! code was made generic for (`serve_peer` over any `AsyncRead+AsyncWrite`). - //! The `OrchestratorService` is wired over the **same** in-memory fakes the - //! infrastructure MCP tests use (`infrastructure/tests/mcp_server.rs`), so MCP - //! behaviour is asserted against a real service with zero I/O. - //! - //! GARDE-FOU : every `await` that could block on a peer that never speaks is - //! bounded by `tokio::time::timeout`; a hung accept/serve fails fast instead of - //! hanging the suite. - - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - - use async_trait::async_trait; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - use application::{ - CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, TerminalSessions, UpdateAgentContext, - }; - use domain::agent::{AgentManifest, ManifestEntry}; - use domain::events::{DomainEvent, OrchestrationSource}; - use domain::ids::{AgentId, ProfileId, ProjectId, SkillId}; - use domain::markdown::MarkdownDoc; - use domain::ports::{ - AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, - PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, - StoreError, - }; - use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, - StructuredAdapter, - }; - use domain::project::{Project, ProjectPath}; - use domain::remote::RemoteRef; - use domain::skill::{Skill, SkillScope}; - use domain::{PtySize, SessionId}; - use serde_json::{json, Value}; - use uuid::Uuid; - - use super::serve_peer; - use infrastructure::McpServer; - - /// Test timeout for any single peer interaction. Generous but finite: a correct - /// duplex round-trip is sub-millisecond, so this only ever fires on a real hang. - const TIMEOUT: Duration = Duration::from_secs(5); - - // ----------------------------------------------------------------------- - // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established - // MCP harness), trimmed to exactly what `OrchestratorService::new` needs. - // ----------------------------------------------------------------------- - - #[derive(Default)] - struct ContextsInner { - manifest: AgentManifest, - contents: HashMap, - } - #[derive(Clone)] - struct FakeContexts(Arc>); - impl FakeContexts { - fn new() -> Self { - Self(Arc::new(Mutex::new(ContextsInner { - manifest: AgentManifest { - version: 1, - entries: Vec::new(), - orchestrator: None, - }, - contents: HashMap::new(), - }))) - } - fn seed_agent(&self, name: &str) -> AgentId { - let id = AgentId::from_uuid(Uuid::new_v4()); - let mut inner = self.0.lock().unwrap(); - inner.manifest.entries.push(ManifestEntry { - agent_id: id, - name: name.to_owned(), - md_path: format!("agents/{name}.md"), - profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), - template_id: None, - synchronized: false, - synced_template_version: None, - skills: Vec::new(), - }); - id - } - fn md_path_of(&self, agent: &AgentId) -> Option { - self.0 - .lock() - .unwrap() - .manifest - .entries - .iter() - .find(|e| &e.agent_id == agent) - .map(|e| e.md_path.clone()) - } - } - #[async_trait] - impl AgentContextStore for FakeContexts { - async fn read_context( - &self, - _project: &Project, - agent: &AgentId, - ) -> Result { - let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - Ok(MarkdownDoc::new( - self.0 - .lock() - .unwrap() - .contents - .get(&md) - .cloned() - .unwrap_or_default(), - )) - } - async fn write_context( - &self, - _project: &Project, - agent: &AgentId, - md: &MarkdownDoc, - ) -> Result<(), StoreError> { - let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - self.0 - .lock() - .unwrap() - .contents - .insert(path, md.as_str().to_owned()); - Ok(()) - } - async fn load_manifest(&self, _project: &Project) -> Result { - Ok(self.0.lock().unwrap().manifest.clone()) - } - async fn save_manifest( - &self, - _project: &Project, - manifest: &AgentManifest, - ) -> Result<(), StoreError> { - self.0.lock().unwrap().manifest = manifest.clone(); - Ok(()) - } - } - - #[derive(Clone)] - struct FakeProfiles(Arc>); - #[async_trait] - impl ProfileStore for FakeProfiles { - async fn list(&self) -> Result, StoreError> { - Ok((*self.0).clone()) - } - async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> { - Ok(()) - } - async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { - Ok(()) - } - async fn is_configured(&self) -> Result { - Ok(true) - } - async fn mark_configured(&self) -> Result<(), StoreError> { - Ok(()) - } - } - - #[derive(Default)] - struct FakeSkills; - #[async_trait] - impl SkillStore for FakeSkills { - async fn list( - &self, - _scope: SkillScope, - _root: &ProjectPath, - ) -> Result, StoreError> { - Ok(Vec::new()) - } - async fn get( - &self, - _scope: SkillScope, - _root: &ProjectPath, - _id: SkillId, - ) -> Result { - Err(StoreError::NotFound) - } - async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { - Ok(()) - } - async fn delete( - &self, - _scope: SkillScope, - _root: &ProjectPath, - _id: SkillId, - ) -> Result<(), StoreError> { - Ok(()) - } - } - - #[derive(Default)] - struct FakeRecall; - #[async_trait] - impl domain::ports::MemoryRecall for FakeRecall { - async fn recall( - &self, - _root: &ProjectPath, - _query: &domain::ports::MemoryQuery, - ) -> Result, domain::ports::MemoryError> { - Ok(Vec::new()) - } - } - - struct FakeRuntime; - #[async_trait] - impl AgentRuntime for FakeRuntime { - async fn detect(&self, _p: &AgentProfile) -> Result { - Ok(true) - } - fn prepare_invocation( - &self, - profile: &AgentProfile, - _ctx: &PreparedContext, - cwd: &ProjectPath, - _session: &SessionPlan, - ) -> Result { - Ok(SpawnSpec { - command: profile.command.clone(), - args: profile.args.clone(), - cwd: cwd.clone(), - env: Vec::new(), - context_plan: Some(ContextInjectionPlan::Stdin), - sandbox: None, - }) - } - } - - #[derive(Clone, Default)] - struct FakeFs; - #[async_trait] - impl FileSystem for FakeFs { - async fn read(&self, p: &RemotePath) -> Result, FsError> { - Err(FsError::NotFound(p.as_str().to_owned())) - } - async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { - Ok(()) - } - async fn exists(&self, _p: &RemotePath) -> Result { - Ok(false) - } - async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - async fn list(&self, _p: &RemotePath) -> Result, FsError> { - Ok(Vec::new()) - } - async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - } - - #[derive(Clone)] - struct FakePty; - #[async_trait] - impl PtyPort for FakePty { - async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { - Ok(PtyHandle { - session_id: SessionId::from_uuid(Uuid::from_u128(777)), - }) - } - fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { - Ok(()) - } - fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { - Ok(()) - } - fn subscribe_output(&self, _h: &PtyHandle) -> Result { - Ok(Box::new(std::iter::empty())) - } - fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { - Ok(Vec::new()) - } - async fn wait(&self, _h: &PtyHandle) -> Result { - Ok(ExitStatus { code: Some(0) }) - } - fn try_wait(&self, _h: &PtyHandle) -> Result, PtyError> { - Ok(Some(ExitStatus { code: Some(0) })) - } - async fn kill(&self, _h: &PtyHandle) -> Result { - Ok(ExitStatus { code: Some(0) }) - } - } - - #[derive(Default, Clone)] - struct NoopBus; - impl EventBus for NoopBus { - fn publish(&self, _e: DomainEvent) {} - fn subscribe(&self) -> EventStream { - Box::new(std::iter::empty()) - } - } - - struct SeqIds(Mutex); - impl IdGenerator for SeqIds { - fn new_uuid(&self) -> Uuid { - let mut n = self.0.lock().unwrap(); - let id = Uuid::from_u128(*n); - *n += 1; - id - } - } - - fn project() -> Project { - Project::new( - ProjectId::from_uuid(Uuid::from_u128(1000)), - "demo", - ProjectPath::new("/home/me/proj").unwrap(), - RemoteRef::local(), - 1_700_000_000_000, - ) - .unwrap() - } - - /// The hyphen-free hex project-id the handshake guard compares against — the - /// exact form `ensure_mcp_server` derives and M5d's `--project` reuses. - fn project_id_arg(p: &Project) -> String { - p.id.as_uuid().simple().to_string() - } - - /// A capturing event sink (the MCP twin of the file watcher's publish closure): - /// records every [`DomainEvent`] so a test can assert `requester_id`. - fn capturing_events() -> ( - Arc, - Arc>>, - ) { - let captured = Arc::new(Mutex::new(Vec::new())); - let sink = captured.clone(); - let publish: Arc = - Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e)); - (publish, captured) - } - - /// Builds an `OrchestratorService` over the in-memory fakes (no structured - /// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`. - fn build_service(contexts: FakeContexts) -> Arc { - // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : - // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour - // `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. - let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( - ProfileId::from_uuid(Uuid::from_u128(9)), - "Claude Code", - "claude", - Vec::new(), - ContextInjection::stdin(), - None, - "{agentRunDir}", - None, - ) - .unwrap() - .with_structured_adapter(StructuredAdapter::Claude) - .with_mcp(McpCapability::new( - McpConfigStrategy::config_file(".mcp.json").unwrap(), - McpTransport::Stdio, - ))]))); - let sessions = Arc::new(TerminalSessions::new()); - let bus = Arc::new(NoopBus); - let create = Arc::new(CreateAgentFromScratch::new( - Arc::new(contexts.clone()), - Arc::new(SeqIds(Mutex::new(1))), - bus.clone(), - )); - let launch = Arc::new(LaunchAgent::new( - Arc::new(contexts.clone()), - Arc::clone(&profiles) as Arc, - Arc::new(FakeRuntime), - Arc::new(FakeFs), - Arc::new(FakePty), - Arc::new(FakeSkills), - Arc::clone(&sessions), - bus.clone(), - Arc::new(SeqIds(Mutex::new(1))), - Arc::new(FakeRecall), - None, - )); - let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); - let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); - let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); - let create_skill = Arc::new(CreateSkill::new( - Arc::new(FakeSkills) as Arc, - Arc::new(SeqIds(Mutex::new(1))), - )); - Arc::new(OrchestratorService::new( - create, - launch, - list, - close, - update, - create_skill, - Arc::clone(&profiles) as Arc, - Arc::clone(&sessions), - )) - } - - // --- duplex client helpers --------------------------------------------- - - /// Frames a `tools/call` request line (newline-terminated, as the transport - /// expects per `StdioTransport::recv`). - fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String { - let mut s = serde_json::to_string(&json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": tool, "arguments": arguments } - })) - .unwrap(); - s.push('\n'); - s - } - - /// A `tools/list` request line. - fn tools_list_line(id: i64) -> String { - let mut s = serde_json::to_string(&json!({ - "jsonrpc": "2.0", "id": id, "method": "tools/list" - })) - .unwrap(); - s.push('\n'); - s - } - - /// A handshake line `{"project":..,"requester":..}` followed by `\n`. - fn handshake_line(project: &str, requester: &str) -> String { - format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n") - } - - /// Reads exactly one newline-delimited JSON-RPC response off the client side of - /// the duplex, bounded by [`TIMEOUT`]. Returns `None` on EOF/timeout (the peer - /// closed without replying — used by the project-guard test). - async fn read_one_response(client: &mut R) -> Option - where - R: tokio::io::AsyncRead + Unpin, - { - let mut buf = Vec::new(); - let mut byte = [0u8; 1]; - loop { - match tokio::time::timeout(TIMEOUT, client.read(&mut byte)).await { - Ok(Ok(0)) => return None, // EOF before a full line - Ok(Ok(_)) => { - if byte[0] == b'\n' { - break; - } - buf.push(byte[0]); - } - Ok(Err(_)) => return None, - Err(_) => return None, // GARDE-FOU: timed out waiting for a reply - } - } - serde_json::from_slice(&buf).ok() - } - - /// Spawns `serve_peer` over the server half of a fresh duplex and returns the - /// client half plus the join handle. The peer is bounded by the test's reads. - fn spawn_peer( - server: Arc, - expected_project: String, - ) -> (tokio::io::DuplexStream, tokio::task::JoinHandle<()>) { - let (client, server_side) = tokio::io::duplex(64 * 1024); - let handle = tokio::spawn(async move { - serve_peer(server, &expected_project, server_side).await; - }); - (client, handle) - } - - // ----------------------------------------------------------------------- - // 1. Handshake + tools/list end-to-end over the duplex. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn handshake_then_tools_list_round_trips_over_duplex() { - let proj = project(); - let service = build_service(FakeContexts::new()); - let server = Arc::new(McpServer::new(service, proj.clone())); - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - // Write the handshake line, then a tools/list request. - client - .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) - .await - .unwrap(); - client - .write_all(tools_list_line(1).as_bytes()) - .await - .unwrap(); - client.flush().await.unwrap(); - - let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client)) - .await - .expect("GARDE-FOU: tools/list timed out") - .expect("a tools/list response line"); - - assert_eq!(resp["id"], json!(1)); - let tools = resp["result"]["tools"].as_array().expect("tools array"); - let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); - for expected in [ - "idea_list_agents", - "idea_ask_agent", - "idea_run_in_background", - "idea_launch_agent", - "idea_stop_agent", - "idea_update_context", - "idea_create_skill", - // FileGuard-mediated context/memory tools (cadrage C7). - "idea_context_read", - "idea_context_propose", - "idea_memory_read", - "idea_memory_write", - // Skill-awareness : lecture à la demande du corps d'un skill. - "idea_skill_read", - // Conversation inter-agent headless : réponse inline capturée depuis le Final. - "idea_ask_agent", - // Live-state (programme live-state, lot LS4). - "idea_workstate_read", - "idea_workstate_set", - // Public ticket tools (Issue domain). - "idea_ticket_create", - "idea_ticket_read", - "idea_ticket_list", - "idea_ticket_update", - "idea_ticket_update_status", - "idea_ticket_update_priority", - "idea_ticket_read_carnet", - "idea_ticket_update_carnet", - "idea_ticket_link", - "idea_ticket_unlink", - "idea_sprint_list", - ] { - assert!( - names.contains(&expected), - "missing tool {expected}; got {names:?}" - ); - } - assert!(!names.contains(&"idea_reply")); - assert_eq!( - tools.len(), - 25, - "exactly the twenty-five exposed idea_* tools; got {names:?}" - ); - - drop(client); // EOF ⇒ serve loop ends - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer task did not finish") - .unwrap(); - } - - // ----------------------------------------------------------------------- - // 2. The handshake's requester is propagated to the processed event. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn handshake_requester_propagates_to_processed_event() { - let proj = project(); - let contexts = FakeContexts::new(); - contexts.seed_agent("architect"); - let service = build_service(contexts); - let (publish, captured) = capturing_events(); - let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - client - .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes()) - .await - .unwrap(); - client - .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) - .await - .unwrap(); - client.flush().await.unwrap(); - - let resp = read_one_response(&mut client) - .await - .expect("a tools/call response"); - assert_eq!(resp["result"]["isError"], json!(false), "got {resp}"); - - drop(client); - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer did not finish") - .unwrap(); - - let events = captured.lock().unwrap(); - let processed: Vec<&DomainEvent> = events - .iter() - .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) - .collect(); - assert_eq!( - processed.len(), - 1, - "exactly one processed event; got {events:?}" - ); - match processed[0] { - DomainEvent::OrchestratorRequestProcessed { - requester_id, - action, - source, - .. - } => { - assert_eq!( - requester_id, "agent-42", - "the real handshake requester must be propagated (not 'mcp')" - ); - assert_eq!(action, "idea_list_agents"); - assert_eq!(*source, OrchestrationSource::Mcp); - } - other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), - } - } - - // ----------------------------------------------------------------------- - // 3. Empty requester in the handshake ⇒ legacy "mcp" label (back-compat). - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn empty_requester_handshake_falls_back_to_legacy_mcp_label() { - let proj = project(); - let contexts = FakeContexts::new(); - contexts.seed_agent("architect"); - let service = build_service(contexts); - let (publish, captured) = capturing_events(); - let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); - // Empty requester ("") in the handshake. - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - client - .write_all(handshake_line(&project_id_arg(&proj), "").as_bytes()) - .await - .unwrap(); - client - .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) - .await - .unwrap(); - client.flush().await.unwrap(); - - let _ = read_one_response(&mut client).await.expect("a response"); - drop(client); - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer did not finish") - .unwrap(); - - let events = captured.lock().unwrap(); - let processed = events - .iter() - .find_map(|e| match e { - DomainEvent::OrchestratorRequestProcessed { requester_id, .. } => { - Some(requester_id.clone()) - } - _ => None, - }) - .expect("a processed event"); - assert_eq!( - processed, "mcp", - "empty handshake requester must keep the legacy 'mcp' label" - ); - } - - // ----------------------------------------------------------------------- - // 4. JSON-RPC bytes glued onto the handshake's write are not lost - // (proves `StdioTransport::from_buffered` preserves buffered bytes). - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn jsonrpc_request_glued_to_handshake_is_served() { - let proj = project(); - let service = build_service(FakeContexts::new()); - let server = Arc::new(McpServer::new(service, proj.clone())); - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - // ONE write carrying the handshake line AND the tools/list line, back to back. - let mut glued = handshake_line(&project_id_arg(&proj), "agent-1"); - glued.push_str(&tools_list_line(7)); - client.write_all(glued.as_bytes()).await.unwrap(); - client.flush().await.unwrap(); - - let resp = read_one_response(&mut client) - .await - .expect("the glued tools/list must still be served"); - assert_eq!( - resp["id"], - json!(7), - "the buffered request id must come through" - ); - assert!( - resp["result"]["tools"].is_array(), - "buffered request produced a real tools/list result; got {resp}" - ); - - drop(client); - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer did not finish") - .unwrap(); - } - - // ----------------------------------------------------------------------- - // 5a. Project guard: a mismatching handshake project ⇒ closed without serving. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn mismatched_project_handshake_is_closed_without_serving() { - let proj = project(); - let contexts = FakeContexts::new(); - contexts.seed_agent("architect"); - let service = build_service(contexts); - let (publish, captured) = capturing_events(); - let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); - // serve_peer is told to expect this project's id... - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - // ...but the handshake claims a DIFFERENT project. - client - .write_all(handshake_line("ffffffffffffffffffffffffffffffff", "intruder").as_bytes()) - .await - .unwrap(); - client - .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) - .await - .unwrap(); - client.flush().await.unwrap(); - - // No reply must come: the peer returned before serving. read returns EOF. - let resp = read_one_response(&mut client).await; - assert!( - resp.is_none(), - "mismatched project must be closed WITHOUT a response; got {resp:?}" - ); - - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer did not finish") - .unwrap(); - - // And nothing was dispatched ⇒ no processed event. - let events = captured.lock().unwrap(); - assert!( - !events - .iter() - .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })), - "a rejected peer must not dispatch anything; got {events:?}" - ); - } - - // ----------------------------------------------------------------------- - // 5b. Empty handshake project ⇒ served normally (guard does not reject). - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn empty_handshake_project_is_served_normally() { - let proj = project(); - let service = build_service(FakeContexts::new()); - let server = Arc::new(McpServer::new(service, proj.clone())); - let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); - - // Empty project in the handshake — the guard must NOT reject it. - client - .write_all(handshake_line("", "agent-1").as_bytes()) - .await - .unwrap(); - client - .write_all(tools_list_line(1).as_bytes()) - .await - .unwrap(); - client.flush().await.unwrap(); - - let resp = read_one_response(&mut client) - .await - .expect("empty-project handshake must still be served"); - assert!(resp["result"]["tools"].is_array(), "got {resp}"); - - drop(client); - tokio::time::timeout(TIMEOUT, peer) - .await - .expect("GARDE-FOU: peer did not finish") - .unwrap(); - } - - // ----------------------------------------------------------------------- - // 6. Peer isolation: one peer closing/erroring does not stop another. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn one_peer_failure_does_not_affect_a_concurrent_peer() { - let proj = project(); - let service = build_service(FakeContexts::new()); - let server = Arc::new(McpServer::new(service, proj.clone())); - - // Peer A: a broken peer that immediately closes after a partial handshake - // (no newline) — its serve_peer must end without crashing the runtime. - let (mut client_a, peer_a) = spawn_peer(Arc::clone(&server), project_id_arg(&proj)); - client_a.write_all(b"{\"project\":").await.unwrap(); // partial, no newline - client_a.flush().await.unwrap(); - drop(client_a); // abrupt close mid-handshake - - // Peer B: a healthy peer, served concurrently, must still get its reply. - let (mut client_b, peer_b) = spawn_peer(Arc::clone(&server), project_id_arg(&proj)); - client_b - .write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes()) - .await - .unwrap(); - client_b - .write_all(tools_list_line(2).as_bytes()) - .await - .unwrap(); - client_b.flush().await.unwrap(); - - let resp = read_one_response(&mut client_b) - .await - .expect("healthy peer B must be served despite peer A failing"); - assert_eq!(resp["id"], json!(2)); - assert!(resp["result"]["tools"].is_array(), "got {resp}"); - - drop(client_b); - // Both peer tasks must terminate cleanly within the bound. - tokio::time::timeout(TIMEOUT, peer_a) - .await - .expect("GARDE-FOU: peer A did not finish") - .unwrap(); - tokio::time::timeout(TIMEOUT, peer_b) - .await - .expect("GARDE-FOU: peer B did not finish") - .unwrap(); - } - - // ----------------------------------------------------------------------- - // Bonus — parse_handshake unit behaviour (tolerant parsing contract). - // ----------------------------------------------------------------------- - - #[test] - fn parse_handshake_is_tolerant() { - use super::parse_handshake; - // Well-formed. - assert_eq!( - parse_handshake("{\"project\":\"p1\",\"requester\":\"a1\"}\n"), - ("p1".to_owned(), "a1".to_owned()) - ); - // Malformed JSON ⇒ empty strings, never a panic. - assert_eq!(parse_handshake("{not json"), (String::new(), String::new())); - // Missing fields ⇒ empty strings. - assert_eq!(parse_handshake("{}"), (String::new(), String::new())); - } - - // ----------------------------------------------------------------------- - // C7 — câblage du FileGuard context/memory au composition root. - // - // Régression visée (fix `fix/wire-context-guard`) : si quelqu'un oublie à - // nouveau `.with_context_guard(...)`, `require_context_guard()` retombe sur - // `None` et toute commande `context.*`/`memory.*` échoue avec - // `AppError::Invalid("FileGuard context/memory tools are not configured")`. - // Ces tests prouvent que, branché comme dans `App::build`, le service - // traite ces commandes sans cette erreur — et le réfute sans le câblage. - // ----------------------------------------------------------------------- - - use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory}; - use domain::conversation::ConversationParty; - use domain::memory::{ - Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, - }; - use domain::ports::{Clock, MemoryError, MemoryStore}; - use domain::OrchestratorCommand; - use infrastructure::RwFileGuard; - - /// In-memory [`MemoryStore`] (slug → body), the lightest fake that lets the - /// `memory.write` → `memory.read` round-trip exercise the real use cases - /// behind the shared guard. Mirrors the one in `context_guard.rs`'s tests. - #[derive(Default)] - struct FakeMemory { - notes: Mutex>, - } - #[async_trait] - impl MemoryStore for FakeMemory { - async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { - Ok(Vec::new()) - } - async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { - let body = self - .notes - .lock() - .unwrap() - .get(slug.as_str()) - .cloned() - .ok_or(MemoryError::NotFound)?; - Memory::new( - MemoryFrontmatter { - name: slug.clone(), - description: "d".to_owned(), - r#type: MemoryType::Project, - }, - MarkdownDoc::new(body), - ) - .map_err(|e| MemoryError::Frontmatter(e.to_string())) - } - async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { - self.notes - .lock() - .unwrap() - .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); - Ok(()) - } - async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { - Ok(()) - } - async fn read_index( - &self, - _root: &ProjectPath, - ) -> Result, MemoryError> { - Ok(Vec::new()) - } - async fn resolve_links( - &self, - _root: &ProjectPath, - _slug: &MemorySlug, - ) -> Result, MemoryError> { - Ok(Vec::new()) - } - } - - /// Fixed millis clock — `ProposeContext` needs a [`Clock`], unused on the - /// paths these tests drive. - struct FixedClock; - impl Clock for FixedClock { - fn now_millis(&self) -> i64 { - 1_700_000_000_000 - } - } - - /// Builds the **same wiring as `App::build`** (state.rs:1070-1129): one - /// shared `RwFileGuard` cast once, cloned into the four C7 use cases, handed - /// to the service via `.with_context_guard(...)`. Returns the service plus - /// the shared `FakeMemory` so a test can assert the persisted note. - fn build_service_with_guard( - contexts: FakeContexts, - ) -> (Arc, Arc) { - let memory = Arc::new(FakeMemory::default()); - let file_guard = Arc::new(RwFileGuard::new()) as Arc; - let context_guard = Arc::new(ContextGuardUseCases { - read_context: Arc::new(ReadContext::new( - Arc::clone(&file_guard), - Arc::new(contexts.clone()), - Arc::new(FakeFs), - )), - propose_context: Arc::new(ProposeContext::new( - Arc::clone(&file_guard), - Arc::new(contexts.clone()), - Arc::new(FakeFs), - Arc::new(FixedClock), - )), - read_memory: Arc::new(ReadMemory::new( - Arc::clone(&file_guard), - Arc::clone(&memory) as Arc, - )), - write_memory: Arc::new(WriteMemory::new( - Arc::clone(&file_guard), - Arc::clone(&memory) as Arc, - )), - }); - // Rewrap `build_service`'s service with the guard. `build_service` - // already produces a fully-wired `OrchestratorService` over the same - // fakes; `.with_context_guard` is additive, exactly the prod builder. - let service = build_service(contexts); - let service = Arc::try_unwrap(service) - .map_err(|_| ()) - .expect("freshly built service is uniquely owned") - .with_context_guard(context_guard); - (Arc::new(service), memory) - } - - /// The party an MCP agent presents to the guard — an agent (not the - /// orchestrator), the realistic caller of `idea_memory_*`/`idea_context_*`. - fn agent_party(n: u128) -> ConversationParty { - ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n))) - } - - // ----------------------------------------------------------------------- - // LS4 — live-state round-trip through the app-tauri-wired service. - // ----------------------------------------------------------------------- - - /// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip - /// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the - /// service's write/read providers without touching the filesystem. - #[derive(Default)] - struct InMemLiveStore { - state: Mutex, - } - #[async_trait] - impl domain::ports::LiveStateStore for InMemLiveStore { - async fn load(&self) -> Result { - Ok(self.state.lock().unwrap().clone()) - } - async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> { - self.state.lock().unwrap().upsert(entry); - Ok(()) - } - async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { - self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); - Ok(()) - } - } - - struct WsWriteProvider { - update: Arc, - } - impl application::LiveStateProvider for WsWriteProvider { - fn live_state_for(&self, _root: &ProjectPath) -> Option> { - Some(Arc::clone(&self.update)) - } - } - struct WsReadProvider { - getter: Arc, - } - impl application::LiveStateReadProvider for WsReadProvider { - fn live_state_lean_for( - &self, - _root: &ProjectPath, - ) -> Option> { - Some(Arc::clone(&self.getter)) - } - } - - /// Like [`build_service_with_guard`] but additively wires the LS4 write+read - /// providers over a SHARED in-memory store, exactly the prod builder's - /// `.with_live_state(...).with_live_state_read(...)`. - fn build_service_with_live_state(contexts: FakeContexts) -> Arc { - let store = Arc::new(InMemLiveStore::default()) as Arc; - let clock = Arc::new(FixedClock) as Arc; - let write = Arc::new(WsWriteProvider { - update: Arc::new(application::UpdateLiveState::new( - Arc::clone(&store), - Arc::clone(&clock), - )), - }); - let read = Arc::new(WsReadProvider { - getter: Arc::new(application::GetLiveStateLean::new( - Arc::clone(&store), - Arc::clone(&clock), - )), - }); - let service = build_service(contexts); - let service = Arc::try_unwrap(service) - .map_err(|_| ()) - .expect("freshly built service is uniquely owned") - .with_live_state(write as Arc) - .with_live_state_read(read as Arc); - Arc::new(service) - } - - /// END-TO-END (lot LS4) — through the **app-tauri-wired** `OrchestratorService`, - /// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's - /// row comes back with the declared status/intent and the **resolved display name** - /// (via `ListAgents`), and `progress` never surfaces. - #[tokio::test] - async fn wired_serves_workstate_set_then_read_round_trip() { - let proj = project(); - let contexts = FakeContexts::new(); - let agent = contexts.seed_agent("architect"); - let service = build_service_with_live_state(contexts); - - // set — keyed on the agent identity; a progress note is supplied (must not leak). - let ack = service - .dispatch( - &proj, - domain::OrchestratorCommand::SetWorkState { - agent, - status: domain::live_state::WorkStatus::Working, - intent: Some("ship LS4".to_owned()), - progress: Some("hidden note".to_owned()), - ticket: None, - last_delegation: None, - }, - ) - .await - .expect("set must succeed when live-state is wired"); - assert!(ack.reply.is_none(), "set is ACK only: {ack:?}"); - - // read — over the SAME store, the architect's row comes back resolved. - let out = service - .dispatch( - &proj, - domain::OrchestratorCommand::ReadWorkState { - requester: ConversationParty::User, - }, - ) - .await - .expect("read must succeed when live-state is wired"); - let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap(); - let rows = json.as_array().expect("array"); - assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}"); - assert_eq!( - rows[0]["agent"], - json!("architect"), - "name resolved: {json}" - ); - assert_eq!(rows[0]["status"], json!("working")); - assert_eq!(rows[0]["intent"], json!("ship LS4")); - assert!( - rows[0].get("progress").is_none(), - "progress must never surface on read: {json}" - ); - } - - /// WIRING — with `.with_context_guard(...)`, `memory.write` then - /// `memory.read` round-trips the content instead of erroring "not - /// configured". Proves the four use cases reached the dispatch. - #[tokio::test] - async fn wired_guard_serves_memory_read_write_round_trip() { - let proj = project(); - let (service, _memory) = build_service_with_guard(FakeContexts::new()); - - // memory.write — must not return the "not configured" sentinel. - let write = service - .dispatch( - &proj, - OrchestratorCommand::WriteMemory { - slug: "wiring-note".to_owned(), - content: "guard is wired".to_owned(), - requester: agent_party(1), - }, - ) - .await - .expect("memory.write must succeed when the guard is wired"); - assert_eq!(write.detail, "wrote memory wiring-note"); - - // memory.read on the SAME shared guard/store — returns the body. - let read = service - .dispatch( - &proj, - OrchestratorCommand::ReadMemory { - slug: Some("wiring-note".to_owned()), - requester: agent_party(2), - }, - ) - .await - .expect("memory.read must succeed when the guard is wired"); - assert_eq!(read.reply.as_deref(), Some("guard is wired")); - } - - /// WIRING — `context.read` on a seeded agent target returns its `.md` body - /// (not the "not configured" error), exercising `ReadContext` end-to-end. - #[tokio::test] - async fn wired_guard_serves_context_read() { - let proj = project(); - let contexts = FakeContexts::new(); - let agent = contexts.seed_agent("dev-backend"); - // Seed the agent's context body via the store the use case reads from. - { - let md = contexts.md_path_of(&agent).unwrap(); - contexts - .0 - .lock() - .unwrap() - .contents - .insert(md, "# Dev Backend context".to_owned()); - } - let (service, _memory) = build_service_with_guard(contexts); - - let out = service - .dispatch( - &proj, - OrchestratorCommand::ReadContext { - target: Some("dev-backend".to_owned()), - requester: agent_party(3), - }, - ) - .await - .expect("context.read must succeed when the guard is wired"); - assert_eq!(out.reply.as_deref(), Some("# Dev Backend context")); - assert_eq!(out.detail, "read dev-backend context"); - } - - /// SYMMETRY — without the guard, the very same command yields the typed - /// `AppError::Invalid("FileGuard … not configured")`. Documents the contract - /// of `require_context_guard` and pins the exact regression message. - #[tokio::test] - async fn unwired_service_rejects_memory_write_with_typed_error() { - let proj = project(); - // `build_service` does NOT call `.with_context_guard`. - let service = build_service(FakeContexts::new()); - - let err = service - .dispatch( - &proj, - OrchestratorCommand::WriteMemory { - slug: "wiring-note".to_owned(), - content: "x".to_owned(), - requester: agent_party(1), - }, - ) - .await - .expect_err("memory.write must fail without the guard wired"); - assert_eq!( - err, - application::AppError::Invalid( - "FileGuard context/memory tools are not configured".to_owned() - ) - ); - } -} - -#[cfg(test)] -mod tests { - //! Wiring contract for [`build_memory_recall`] (Pièce 1, §14.5.5). - //! - //! The *behaviour* of `NaiveMemoryRecall` / `VectorMemoryRecall` / - //! `AdaptiveMemoryRecall` is covered exhaustively in - //! `infrastructure/tests/vector_recall.rs`. Here we only pin the **composition - //! root contract**: with the default `EmbedderProfile::none()` profile (the - //! dependency-free default), `build_memory_recall` must hand back a recall whose - //! observable behaviour is *identical* to a bare `NaiveMemoryRecall` over the - //! same store — same index ordering, same budget truncation. - //! - //! `build_memory_recall` is `pub(crate)`, so this lives in `state.rs` (it is not - //! reachable from the `tests/` integration crate). Everything is in-memory and - //! deterministic. - - use std::collections::HashMap; - use std::sync::Mutex; - - use super::*; - use async_trait::async_trait; - use domain::markdown::MarkdownDoc; - use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; - use domain::ports::{DirEntry, FsError, MemoryQuery, RemotePath}; - use domain::project::ProjectPath; - - // In-memory FileSystem (same minimal shape as the infrastructure test fixtures). - #[derive(Default)] - struct MemFs { - files: Mutex>>, - } - - #[async_trait] - impl FileSystem for MemFs { - async fn read(&self, path: &RemotePath) -> Result, FsError> { - self.files - .lock() - .unwrap() - .get(path.as_str()) - .cloned() - .ok_or_else(|| FsError::NotFound(path.as_str().to_string())) - } - async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { - self.files - .lock() - .unwrap() - .insert(path.as_str().to_string(), data.to_vec()); - Ok(()) - } - async fn exists(&self, path: &RemotePath) -> Result { - Ok(self.files.lock().unwrap().contains_key(path.as_str())) - } - async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - async fn list(&self, _path: &RemotePath) -> Result, FsError> { - Ok(Vec::new()) - } - async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - } - - fn root() -> ProjectPath { - ProjectPath::new("/proj").unwrap() - } - - fn note(slug: &str, hook: &str) -> Memory { - Memory::new( - MemoryFrontmatter { - name: MemorySlug::new(slug).unwrap(), - description: hook.to_string(), - r#type: MemoryType::Project, - }, - MarkdownDoc::new("# body"), - ) - .unwrap() - } - - fn query(text: &str, budget: usize) -> MemoryQuery { - MemoryQuery { - text: text.to_string(), - token_budget: budget, - } - } - - async fn seed_store() -> (Arc, Arc) { - let fs: Arc = Arc::new(MemFs::default()); - let store_concrete = Arc::new(FsMemoryStore::new(Arc::clone(&fs))); - for n in [ - note("alpha", "apple orange grape"), - note("beta", "kiwi mango papaya"), - note("gamma", "carrot potato onion"), - ] { - store_concrete.save(&root(), &n).await.unwrap(); - } - let store: Arc = store_concrete; - (fs, store) - } - - /// With the default `none` profile, `build_memory_recall` is observationally a - /// plain `NaiveMemoryRecall`: same index ordering and same budget truncation, - /// entry-for-entry, across a representative spread of budgets. - #[tokio::test] - async fn build_memory_recall_none_profile_matches_naive_recall() { - let (fs, store) = seed_store().await; - - let wired = build_memory_recall( - Arc::clone(&fs), - Arc::clone(&store), - &EmbedderProfile::none(), - std::path::Path::new("/unused-onnx-cache"), - ); - let naive = NaiveMemoryRecall::new(Arc::clone(&store)); - - // 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order. - for budget in [0usize, 3, 6, 100, 100_000] { - let q = query("kiwi mango papaya", budget); - let expected = naive.recall(&root(), &q).await.unwrap(); - let got = wired.recall(&root(), &q).await.unwrap(); - assert_eq!( - got, expected, - "none profile must equal bare NaiveMemoryRecall at budget {budget}" - ); - } - } -} - -// =========================================================================== -// M5e — Smoke end-to-end over the REAL loopback (no real CLI, no network). -// =========================================================================== - -#[cfg(test)] -mod mcp_e2e_loopback_tests { - //! M5e — **smoke end-to-end** of the bind transport over a **real** - //! `interprocess` loopback (cadrage v5 §6 row M5e, §2 contract). - //! - //! ## Chosen e2e level — the **real accept loop**, justified - //! - //! Unlike M5c (which drove the *private* `serve_peer` over an in-memory - //! `tokio::io::duplex`), M5e proves the chain across an **actual** local socket. - //! Two options were on the table (cadrage M5e): - //! - //! 1. the **full `McpServerHandle::start` accept loop** on a real - //! `mcp_endpoint`, dialed by a real `interprocess` client, or - //! 2. a single `serve_peer` over one accepted real socket. - //! - //! We pick **(1) the real accept loop**. It is the *most faithful* slice — it - //! exercises exactly the production path a launched CLI's `idea mcp-server` - //! bridge takes: `bind_endpoint` → `McpServerHandle::start` → `listener.accept()` - //! → `serve_peer` (handshake + project guard) → `StdioTransport` → - //! `McpServer::serve_as` → the **real `dispatch`** (over the same in-memory fakes - //! as M2/M5c). And it is **safely bounded**: the accept loop is already async and - //! parks on the absence of a peer, while the *client* side is a plain - //! request/response exchange we wrap in `tokio::time::timeout`. Option (2) would - //! skip the bind/accept/lifecycle wiring for no extra safety, since the risk - //! (a peer that never speaks) is bounded identically by the client-side timeout. - //! - //! No production test seam was added: `McpServerHandle::start`, `bind_endpoint` - //! and `mcp_endpoint` are all reachable from this in-crate module as-is. The - //! client uses the same `interprocess` `Stream` the real bridge uses. - //! - //! GARDE-FOU : the listener bind, every client connect, and every response read - //! is wrapped in `tokio::time::timeout`; a hung accept/serve/handshake fails the - //! test fast instead of hanging the suite. - - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - - use async_trait::async_trait; - use interprocess::local_socket::tokio::Stream as LocalSocketStream; - use interprocess::local_socket::traits::tokio::Stream as _; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use uuid::Uuid; - - use application::{ - CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, - }; - use domain::agent::{AgentManifest, ManifestEntry}; - use domain::events::{DomainEvent, OrchestrationSource}; - use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId}; - use domain::markdown::MarkdownDoc; - use domain::ports::{ - AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, - DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, - OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, - ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, - }; - use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, - StructuredAdapter, - }; - use domain::project::{Project, ProjectPath}; - use domain::remote::RemoteRef; - use domain::skill::{Skill, SkillScope}; - use domain::{PtySize, SessionId}; - use serde_json::{json, Value}; - - use super::{bind_endpoint, mcp_endpoint, McpServerHandle}; - use crate::mcp_endpoint::McpEndpoint; - use infrastructure::{ - InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock, - }; - - /// Test timeout for any single loopback interaction. Generous but finite: a - /// correct round-trip is sub-millisecond, so this only ever fires on a real hang. - const TIMEOUT: Duration = Duration::from_secs(5); - - // ----------------------------------------------------------------------- - // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established - // MCP harness). Includes a `FakeSession` so `idea_ask_agent` resolves inline. - // ----------------------------------------------------------------------- - - #[derive(Default)] - struct ContextsInner { - manifest: AgentManifest, - contents: HashMap, - } - #[derive(Clone)] - struct FakeContexts(Arc>); - impl FakeContexts { - fn new() -> Self { - Self(Arc::new(Mutex::new(ContextsInner { - manifest: AgentManifest { - version: 1, - entries: Vec::new(), - orchestrator: None, - }, - contents: HashMap::new(), - }))) - } - fn seed_agent(&self, name: &str) -> AgentId { - let id = AgentId::from_uuid(Uuid::new_v4()); - let mut inner = self.0.lock().unwrap(); - inner.manifest.entries.push(ManifestEntry { - agent_id: id, - name: name.to_owned(), - md_path: format!("agents/{name}.md"), - profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), - template_id: None, - synchronized: false, - synced_template_version: None, - skills: Vec::new(), - }); - id - } - fn md_path_of(&self, agent: &AgentId) -> Option { - self.0 - .lock() - .unwrap() - .manifest - .entries - .iter() - .find(|e| &e.agent_id == agent) - .map(|e| e.md_path.clone()) - } - } - #[async_trait] - impl AgentContextStore for FakeContexts { - async fn read_context( - &self, - _project: &Project, - agent: &AgentId, - ) -> Result { - let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - Ok(MarkdownDoc::new( - self.0 - .lock() - .unwrap() - .contents - .get(&md) - .cloned() - .unwrap_or_default(), - )) - } - async fn write_context( - &self, - _project: &Project, - agent: &AgentId, - md: &MarkdownDoc, - ) -> Result<(), StoreError> { - let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - self.0 - .lock() - .unwrap() - .contents - .insert(path, md.as_str().to_owned()); - Ok(()) - } - async fn load_manifest(&self, _project: &Project) -> Result { - Ok(self.0.lock().unwrap().manifest.clone()) - } - async fn save_manifest( - &self, - _project: &Project, - manifest: &AgentManifest, - ) -> Result<(), StoreError> { - self.0.lock().unwrap().manifest = manifest.clone(); - Ok(()) - } - } - - #[derive(Clone)] - struct FakeProfiles(Arc>); - #[async_trait] - impl ProfileStore for FakeProfiles { - async fn list(&self) -> Result, StoreError> { - Ok((*self.0).clone()) - } - async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> { - Ok(()) - } - async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { - Ok(()) - } - async fn is_configured(&self) -> Result { - Ok(true) - } - async fn mark_configured(&self) -> Result<(), StoreError> { - Ok(()) - } - } - - #[derive(Default)] - struct FakeSkills; - #[async_trait] - impl SkillStore for FakeSkills { - async fn list( - &self, - _scope: SkillScope, - _root: &ProjectPath, - ) -> Result, StoreError> { - Ok(Vec::new()) - } - async fn get( - &self, - _scope: SkillScope, - _root: &ProjectPath, - _id: SkillId, - ) -> Result { - Err(StoreError::NotFound) - } - async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { - Ok(()) - } - async fn delete( - &self, - _scope: SkillScope, - _root: &ProjectPath, - _id: SkillId, - ) -> Result<(), StoreError> { - Ok(()) - } - } - - #[derive(Default)] - struct FakeRecall; - #[async_trait] - impl domain::ports::MemoryRecall for FakeRecall { - async fn recall( - &self, - _root: &ProjectPath, - _query: &domain::ports::MemoryQuery, - ) -> Result, domain::ports::MemoryError> { - Ok(Vec::new()) - } - } - - struct FakeSession { - id: SessionId, - reply: String, - } - - #[async_trait] - impl AgentSession for FakeSession { - fn id(&self) -> SessionId { - self.id - } - - fn conversation_id(&self) -> Option { - Some(self.id.to_string()) - } - - async fn send(&self, _prompt: &str) -> Result { - Ok(Box::new(std::iter::once(ReplyEvent::Final { - content: self.reply.clone(), - }))) - } - - async fn shutdown(&self) -> Result<(), AgentSessionError> { - Ok(()) - } - } - - struct FakeRuntime; - #[async_trait] - impl AgentRuntime for FakeRuntime { - async fn detect(&self, _p: &AgentProfile) -> Result { - Ok(true) - } - fn prepare_invocation( - &self, - profile: &AgentProfile, - _ctx: &PreparedContext, - cwd: &ProjectPath, - _session: &SessionPlan, - ) -> Result { - Ok(SpawnSpec { - command: profile.command.clone(), - args: profile.args.clone(), - cwd: cwd.clone(), - env: Vec::new(), - context_plan: Some(ContextInjectionPlan::Stdin), - sandbox: None, - }) - } - } - - #[derive(Clone, Default)] - struct FakeFs; - #[async_trait] - impl FileSystem for FakeFs { - async fn read(&self, p: &RemotePath) -> Result, FsError> { - Err(FsError::NotFound(p.as_str().to_owned())) - } - async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { - Ok(()) - } - async fn exists(&self, _p: &RemotePath) -> Result { - Ok(false) - } - async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - async fn list(&self, _p: &RemotePath) -> Result, FsError> { - Ok(Vec::new()) - } - async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { - Ok(()) - } - } - - #[derive(Clone)] - struct FakePty; - #[async_trait] - impl PtyPort for FakePty { - async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { - Ok(PtyHandle { - session_id: SessionId::from_uuid(Uuid::from_u128(777)), - }) - } - fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { - Ok(()) - } - fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { - Ok(()) - } - fn subscribe_output(&self, _h: &PtyHandle) -> Result { - Ok(Box::new(std::iter::empty())) - } - fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { - Ok(Vec::new()) - } - async fn wait(&self, _h: &PtyHandle) -> Result { - Ok(ExitStatus { code: Some(0) }) - } - fn try_wait(&self, _h: &PtyHandle) -> Result, PtyError> { - Ok(Some(ExitStatus { code: Some(0) })) - } - async fn kill(&self, _h: &PtyHandle) -> Result { - Ok(ExitStatus { code: Some(0) }) - } - } - - #[derive(Default, Clone)] - struct NoopBus; - impl EventBus for NoopBus { - fn publish(&self, _e: DomainEvent) {} - fn subscribe(&self) -> EventStream { - Box::new(std::iter::empty()) - } - } - - struct SeqIds(Mutex); - impl IdGenerator for SeqIds { - fn new_uuid(&self) -> Uuid { - let mut n = self.0.lock().unwrap(); - let id = Uuid::from_u128(*n); - *n += 1; - id - } - } - - fn project() -> Project { - // A fresh project id per call ⇒ a distinct endpoint per test (no socket-file - // collision when tests run in parallel). - Project::new( - ProjectId::from_uuid(Uuid::new_v4()), - "demo", - ProjectPath::new("/home/me/proj").unwrap(), - RemoteRef::local(), - 1_700_000_000_000, - ) - .unwrap() - } - - fn project_id_arg(p: &Project) -> String { - p.id.as_uuid().simple().to_string() - } - - fn capturing_events() -> ( - Arc, - Arc>>, - ) { - let captured = Arc::new(Mutex::new(Vec::new())); - let sink = captured.clone(); - let publish: Arc = - Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e)); - (publish, captured) - } - - /// Builds an `OrchestratorService` over the in-memory fakes, returning the - /// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an - /// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending - /// tickets). Mirrors the established harness; no use case is re-invented. - fn build_service( - contexts: FakeContexts, - ) -> ( - Arc, - Arc, - Arc, - ) { - // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : - // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour - // `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible. - let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( - ProfileId::from_uuid(Uuid::from_u128(9)), - "Claude Code", - "claude", - Vec::new(), - ContextInjection::stdin(), - None, - "{agentRunDir}", - None, - ) - .unwrap() - .with_structured_adapter(StructuredAdapter::Claude) - .with_mcp(McpCapability::new( - McpConfigStrategy::config_file(".mcp.json").unwrap(), - McpTransport::Stdio, - ))]))); - let sessions = Arc::new(TerminalSessions::new()); - let structured = Arc::new(StructuredSessions::new()); - seed_structured_sessions(&structured, &contexts); - let mailbox = Arc::new(InMemoryMailbox::new()); - let bus = Arc::new(NoopBus); - let create = Arc::new(CreateAgentFromScratch::new( - Arc::new(contexts.clone()), - Arc::new(SeqIds(Mutex::new(1))), - bus.clone(), - )); - let launch = Arc::new(LaunchAgent::new( - Arc::new(contexts.clone()), - Arc::clone(&profiles) as Arc, - Arc::new(FakeRuntime), - Arc::new(FakeFs), - Arc::new(FakePty), - Arc::new(FakeSkills), - Arc::clone(&sessions), - bus.clone(), - Arc::new(SeqIds(Mutex::new(1))), - Arc::new(FakeRecall), - None, - )); - let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); - let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); - let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); - let create_skill = Arc::new(CreateSkill::new( - Arc::new(FakeSkills) as Arc, - Arc::new(SeqIds(Mutex::new(1))), - )); - let input = Arc::new(MediatedInbox::with_pty( - Arc::clone(&mailbox), - Arc::new(SystemMillisClock), - Arc::new(FakePty) as Arc, - )) as Arc; - let conversations = Arc::new(InMemoryConversationRegistry::new()) - as Arc; - let service = OrchestratorService::new( - create, - launch, - list, - close, - update, - create_skill, - Arc::clone(&profiles) as Arc, - Arc::clone(&sessions), - ) - .with_input_mediator( - input, - Arc::clone(&mailbox) as Arc, - ) - .with_conversations(conversations) - .with_structured(structured); - (Arc::new(service), sessions, mailbox) - } - - /// Codex twin of [`build_service`]: identical wiring, but the single profile - /// targets the **Codex** structured adapter with a `TomlConfigHome` MCP strategy - /// (`$CODEX_HOME/config.toml`). This is the couple the bridge guard - /// (`materializes_idea_bridge`) must now let through — proving Codex is eligible - /// for `idea_ask_agent`, exactly like Claude. No real `codex` binary is ever - /// spawned: the runtime/PTY are the same in-memory fakes. - fn build_service_codex( - contexts: FakeContexts, - ) -> ( - Arc, - Arc, - Arc, - ) { - let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( - ProfileId::from_uuid(Uuid::from_u128(9)), - "Codex CLI", - "codex", - Vec::new(), - ContextInjection::stdin(), - None, - "{agentRunDir}", - None, - ) - .unwrap() - .with_structured_adapter(StructuredAdapter::Codex) - .with_mcp(McpCapability::new( - McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(), - McpTransport::Stdio, - ))]))); - let sessions = Arc::new(TerminalSessions::new()); - let structured = Arc::new(StructuredSessions::new()); - seed_structured_sessions(&structured, &contexts); - let mailbox = Arc::new(InMemoryMailbox::new()); - let bus = Arc::new(NoopBus); - let create = Arc::new(CreateAgentFromScratch::new( - Arc::new(contexts.clone()), - Arc::new(SeqIds(Mutex::new(1))), - bus.clone(), - )); - let launch = Arc::new(LaunchAgent::new( - Arc::new(contexts.clone()), - Arc::clone(&profiles) as Arc, - Arc::new(FakeRuntime), - Arc::new(FakeFs), - Arc::new(FakePty), - Arc::new(FakeSkills), - Arc::clone(&sessions), - bus.clone(), - Arc::new(SeqIds(Mutex::new(1))), - Arc::new(FakeRecall), - None, - )); - let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); - let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); - let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); - let create_skill = Arc::new(CreateSkill::new( - Arc::new(FakeSkills) as Arc, - Arc::new(SeqIds(Mutex::new(1))), - )); - let input = Arc::new(MediatedInbox::with_pty( - Arc::clone(&mailbox), - Arc::new(SystemMillisClock), - Arc::new(FakePty) as Arc, - )) as Arc; - let conversations = Arc::new(InMemoryConversationRegistry::new()) - as Arc; - let service = OrchestratorService::new( - create, - launch, - list, - close, - update, - create_skill, - Arc::clone(&profiles) as Arc, - Arc::clone(&sessions), - ) - .with_input_mediator( - input, - Arc::clone(&mailbox) as Arc, - ) - .with_conversations(conversations) - .with_structured(structured); - (Arc::new(service), sessions, mailbox) - } - - fn seed_structured_sessions(sessions: &StructuredSessions, contexts: &FakeContexts) { - let entries = contexts.0.lock().unwrap().manifest.entries.clone(); - for (index, entry) in entries.into_iter().enumerate() { - let session = Arc::new(FakeSession { - id: SessionId::from_uuid(Uuid::from_u128(10_000 + index as u128)), - reply: "the answer is 42".to_owned(), - }); - sessions.insert( - session, - entry.agent_id, - NodeId::from_uuid(Uuid::from_u128(20_000 + index as u128)), - ); - } - } - - // --- real loopback harness --------------------------------------------- - - /// Stands up the **real** production accept-and-serve path on a real - /// `interprocess` loopback for `project`: binds the endpoint via the production - /// `bind_endpoint`, starts `McpServerHandle::start` (the M5c accept loop), and - /// returns the live handle plus the endpoint to dial. - /// - /// Asserts the bind actually succeeded (a `None` listener would make the e2e - /// vacuous) — if the per-user runtime dir is unwritable the test fails loudly - /// rather than silently testing nothing. - fn start_real_server( - service: Arc, - project: &Project, - events: Option>, - ) -> (McpServerHandle, McpEndpoint) { - let endpoint = mcp_endpoint(&project.id); - let listener = bind_endpoint(&endpoint); - assert!( - listener.is_some(), - "M5e needs a real bound listener; bind_endpoint returned None for {:?}", - endpoint.as_cli_arg() - ); - let mut server = McpServer::new(service, project.clone()); - if let Some(publish) = events { - server = server.with_events(publish); - } - let handle = - McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project)); - (handle, endpoint) - } - - /// Connects a **real** `interprocess` client to `endpoint`, bounded by [`TIMEOUT`] - /// so an endpoint that never accepts fails fast (GARDE-FOU). - async fn connect_client(endpoint: &McpEndpoint) -> LocalSocketStream { - use interprocess::local_socket::{GenericFilePath, ToFsName as _}; - let name = endpoint - .as_cli_arg() - .to_fs_name::() - .expect("valid endpoint name"); - tokio::time::timeout(TIMEOUT, LocalSocketStream::connect(name)) - .await - .expect("GARDE-FOU: connect to endpoint timed out") - .expect("connect to a live endpoint") - } - - fn handshake_line(project: &str, requester: &str) -> String { - format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n") - } - - fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String { - let mut s = serde_json::to_string(&json!({ - "jsonrpc": "2.0", - "id": id, - "method": "tools/call", - "params": { "name": tool, "arguments": arguments } - })) - .unwrap(); - s.push('\n'); - s - } - - /// Reads exactly one newline-delimited JSON-RPC response off the client side of - /// the real socket, bounded by [`TIMEOUT`]. `None` on EOF/timeout. - async fn read_one_response(reader: &mut R) -> Option - where - R: AsyncBufReadExt + Unpin, - { - let mut line = String::new(); - match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await { - Ok(Ok(0)) => None, // EOF before a full line - Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(), - Ok(Err(_)) => None, - Err(_) => None, // GARDE-FOU: timed out waiting for a reply - } - } - - // ----------------------------------------------------------------------- - // 1. idea_list_agents e2e over the real loopback ⇒ JSON array of agents. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn list_agents_round_trips_over_real_loopback() { - let contexts = FakeContexts::new(); - contexts.seed_agent("architect"); - contexts.seed_agent("dev-backend"); - let (service, _sessions, _mailbox) = build_service(contexts); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, None); - - let conn = connect_client(&endpoint).await; - let (read_half, mut write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - // Handshake (real project id) + a tools/call idea_list_agents. - write_half - .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) - .await - .unwrap(); - write_half - .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) - .await - .unwrap(); - write_half.flush().await.unwrap(); - - let resp = read_one_response(&mut reader) - .await - .expect("a list_agents response line"); - assert_eq!(resp["id"], json!(1)); - assert!(resp["error"].is_null(), "transport error: {resp}"); - let result = &resp["result"]; - assert_eq!(result["isError"], json!(false), "got {result}"); - - // The inline text is the JSON array of the project's agents. - let text = result["content"][0]["text"].as_str().expect("text block"); - let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array"); - let arr = agents.as_array().expect("array"); - assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}"); - let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect(); - assert!(names.contains(&"architect"), "got {names:?}"); - assert!(names.contains(&"dev-backend"), "got {names:?}"); - - drop(write_half); // EOF ⇒ serve loop ends cleanly - handle.stop(); - } - - // ----------------------------------------------------------------------- - // 2. `idea_ask_agent` e2e over the real loopback under the current headless - // protocol: the orchestrator drives the target's structured session directly - // and returns the captured `Final` inline. No target-side `idea_reply` tool is - // involved. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn ask_then_reply_round_trips_inline_over_real_loopback() { - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("architect"); - let (service, _sessions, mailbox) = build_service(contexts); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, None); - - // Connection A: the asker. - let conn_a = connect_client(&endpoint).await; - let (read_a, mut write_a) = tokio::io::split(conn_a); - let mut reader_a = BufReader::new(read_a); - write_a - .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) - .await - .unwrap(); - write_a - .write_all( - tools_call_line( - 7, - "idea_ask_agent", - json!({ "target": "architect", "task": "What is the answer?" }), - ) - .as_bytes(), - ) - .await - .unwrap(); - write_a.flush().await.unwrap(); - - // The asker receives the structured target's Final inline. - let resp = read_one_response(&mut reader_a) - .await - .expect("an ask response line"); - assert_eq!(resp["id"], json!(7)); - assert!(resp["error"].is_null(), "transport error: {resp}"); - let result = &resp["result"]; - assert_eq!(result["isError"], json!(false), "got {result}"); - assert_eq!( - result["content"][0]["text"].as_str().expect("text block"), - "the answer is 42", - "ask reply must be returned inline over the real loopback; got {result}" - ); - assert_eq!( - mailbox.pending(&agent_id), - 0, - "structured ask must drain its accounting ticket" - ); - - drop(write_a); - handle.stop(); - } - - // ----------------------------------------------------------------------- - // 2b. Codex twin of the round-trip above: the *only* difference is the target - // profile (Codex + TomlConfigHome MCP). It proves the bridge guard now lets - // a Codex target through AND that the inline ask→reply loop still completes. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn ask_then_reply_round_trips_inline_over_real_loopback_codex() { - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("architect"); - let (service, _sessions, mailbox) = build_service_codex(contexts); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, None); - - // Connection A: the asker. - let conn_a = connect_client(&endpoint).await; - let (read_a, mut write_a) = tokio::io::split(conn_a); - let mut reader_a = BufReader::new(read_a); - write_a - .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) - .await - .unwrap(); - write_a - .write_all( - tools_call_line( - 7, - "idea_ask_agent", - json!({ "target": "architect", "task": "What is the answer?" }), - ) - .as_bytes(), - ) - .await - .unwrap(); - write_a.flush().await.unwrap(); - - // The asker receives the structured target's Final inline. - let resp = read_one_response(&mut reader_a) - .await - .expect("an ask response line"); - assert_eq!(resp["id"], json!(7)); - assert!(resp["error"].is_null(), "transport error: {resp}"); - let result = &resp["result"]; - assert_eq!(result["isError"], json!(false), "got {result}"); - assert_eq!( - result["content"][0]["text"].as_str().expect("text block"), - "the answer is 42", - "ask reply must be returned inline over the real loopback (Codex target); got {result}" - ); - assert_eq!( - mailbox.pending(&agent_id), - 0, - "structured ask must drain its accounting ticket" - ); - - drop(write_a); - handle.stop(); - } - - // ----------------------------------------------------------------------- - // 3. `idea_reply` is no longer part of the MCP surface. Calling it returns a - // JSON-RPC unknown-tool error and the connection stays healthy for a follow-up - // call. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn removed_reply_tool_is_unknown_over_real_loopback() { - let contexts = FakeContexts::new(); - let agent_id = contexts.seed_agent("dev"); - let (service, _sessions, _mailbox) = build_service(contexts); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, None); - - let conn = connect_client(&endpoint).await; - let (read_half, mut write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - // The peer identifies as `agent_id`; `idea_reply` itself is no longer mapped. - write_half - .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes()) - .await - .unwrap(); - write_half - .write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes()) - .await - .unwrap(); - write_half.flush().await.unwrap(); - - let resp = read_one_response(&mut reader) - .await - .expect("a reply response line"); - assert_eq!(resp["id"], json!(3)); - assert_eq!(resp["error"]["code"], json!(-32601), "got {resp}"); - assert_eq!( - resp["error"]["message"], - json!("unknown tool: idea_reply"), - "got {resp}" - ); - assert!(resp.get("result").is_none(), "got {resp}"); - - // The connection is still healthy: a follow-up tools/list still answers. - write_half - .write_all( - serde_json::to_string(&json!({ - "jsonrpc": "2.0", "id": 4, "method": "tools/list" - })) - .map(|mut s| { - s.push('\n'); - s - }) - .unwrap() - .as_bytes(), - ) - .await - .unwrap(); - write_half.flush().await.unwrap(); - let again = read_one_response(&mut reader) - .await - .expect("server still serves after a tool error"); - assert_eq!(again["id"], json!(4)); - assert!(again["result"]["tools"].is_array(), "got {again}"); - - drop(write_half); - handle.stop(); - } - - // ----------------------------------------------------------------------- - // 4. Malformed JSON-RPC after the handshake ⇒ JSON-RPC error, never a panic, - // the server stays alive for a subsequent valid call. - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() { - let contexts = FakeContexts::new(); - contexts.seed_agent("architect"); - let (service, _sessions, _mailbox) = build_service(contexts); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, None); - - let conn = connect_client(&endpoint).await; - let (read_half, mut write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - write_half - .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) - .await - .unwrap(); - // A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop. - write_half.write_all(b"{ this is not json\n").await.unwrap(); - write_half.flush().await.unwrap(); - - let resp = read_one_response(&mut reader) - .await - .expect("a parse error still owes a response"); - let error = &resp["error"]; - assert!( - !error.is_null(), - "malformed input must yield a JSON-RPC error: {resp}" - ); - // JSON-RPC mandates a null id when the request could not be correlated. - assert_eq!(resp["id"], Value::Null, "got {resp}"); - assert!( - resp["result"].is_null(), - "no result on parse error; got {resp}" - ); - - // The server survived: a subsequent valid call still answers. - write_half - .write_all( - serde_json::to_string(&json!({ - "jsonrpc": "2.0", "id": 2, "method": "tools/list" - })) - .map(|mut s| { - s.push('\n'); - s - }) - .unwrap() - .as_bytes(), - ) - .await - .unwrap(); - write_half.flush().await.unwrap(); - let again = read_one_response(&mut reader) - .await - .expect("server must survive malformed input"); - assert_eq!(again["id"], json!(2)); - assert!(again["result"]["tools"].is_array(), "got {again}"); - - drop(write_half); - handle.stop(); - } - - // ----------------------------------------------------------------------- - // 5. Bonus — the handshake requester crosses the REAL loopback and tags the - // OrchestratorRequestProcessed event with the real agent id (not "mcp"). - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn handshake_requester_propagates_over_real_loopback() { - let contexts = FakeContexts::new(); - let (service, _sessions, _mailbox) = build_service(contexts); - let (publish, captured) = capturing_events(); - let proj = project(); - let (handle, endpoint) = start_real_server(service, &proj, Some(publish)); - - let conn = connect_client(&endpoint).await; - let (read_half, mut write_half) = tokio::io::split(conn); - let mut reader = BufReader::new(read_half); - - // Handshake carries the real requesting agent id. - write_half - .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes()) - .await - .unwrap(); - // A successful launch (creates + launches an agent from scratch) ⇒ a - // processed beacon is emitted, tagged with the handshake requester. - write_half - .write_all( - tools_call_line( - 1, - "idea_launch_agent", - json!({ "target": "dev-backend", "profile": "claude-code" }), - ) - .as_bytes(), - ) - .await - .unwrap(); - write_half.flush().await.unwrap(); - - let resp = read_one_response(&mut reader) - .await - .expect("a launch response line"); - assert!(resp["error"].is_null(), "transport error: {resp}"); - assert_eq!(resp["result"]["isError"], json!(false), "got {resp}"); - - // Drain the connection so the serve task has surely published the event. - drop(write_half); - // Give the spawned serve task a beat to flush its publish (bounded). - let _ = tokio::time::timeout(TIMEOUT, async { - loop { - if captured - .lock() - .unwrap() - .iter() - .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await; - - let events = captured.lock().unwrap(); - let processed: Vec<&DomainEvent> = events - .iter() - .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) - .collect(); - assert_eq!( - processed.len(), - 1, - "expected exactly one processed event; got {events:?}" - ); - match processed[0] { - DomainEvent::OrchestratorRequestProcessed { - requester_id, - action, - ok, - source, - } => { - assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp"); - assert_eq!(action, "idea_launch_agent"); - assert!(*ok, "the launch succeeded"); - assert_eq!( - requester_id, "agent-42", - "the real handshake requester must cross the loopback (not 'mcp')" - ); - } - other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), - } - drop(events); - - handle.stop(); - } -} - -#[cfg(test)] -mod bind_endpoint_d1_tests { - //! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim - //! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`"). - //! - //! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain - //! `bind` would then fail with `EADDRINUSE`. The production path passes - //! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests - //! pin that behaviour so a future refactor cannot silently drop the flag and - //! resurrect the "address already in use" failure on restart. - //! - //! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to - //! reclaim, so there is nothing to assert. - - #![cfg(unix)] - - use super::{bind_endpoint, mcp_endpoint}; - use domain::ProjectId; - use uuid::Uuid; - - /// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL) - /// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first. - /// - /// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener` - /// binds the path then is dropped — std does **not** unlink on drop, so the socket - /// inode is left on the filesystem with **no live listener**, exactly the state a - /// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind` - /// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)` - /// must unlink it first. - #[tokio::test] - async fn rebind_after_corpse_socket_succeeds() { - use std::os::unix::net::UnixListener; - - let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001))); - let path = ep - .socket_path() - .expect("unix endpoint exposes a socket path"); - - // Clean any leftover from a previous run of this very test. - let _ = std::fs::remove_file(&path); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - - // 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and - // drop it ⇒ socket file remains with no live listener (SIGKILL aftermath). - { - let corpse = UnixListener::bind(&path).expect("lay corpse socket"); - drop(corpse); - } - assert!( - path.exists(), - "corpse socket inode remains (no live listener)" - ); - - // 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT - // fail with EADDRINUSE. - let first = bind_endpoint(&ep); - assert!( - first.is_some(), - "rebind over the corpse socket must succeed (reclaim_name unlinks it)" - ); - assert!(path.exists(), "a fresh live socket now sits at the path"); - - // 3) Idempotent over a live socket: a second bind also reclaims + succeeds. - let second = bind_endpoint(&ep); - assert!(second.is_some(), "bind is idempotent across a live socket"); - - // 4) No file leak after a clean close: dropping the live listener(s) unlinks - // the socket (interprocess reclaim guard on drop). - drop(first); - drop(second); - assert!( - !path.exists(), - "socket file unlinked on clean close (no leak)" - ); + fn deref(&self) -> &Self::Target { + &self.core } } diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs index 4bcf510..396e3d0 100644 --- a/crates/app-tauri/tests/orchestrator_wiring.rs +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -242,6 +242,7 @@ fn socket_exists(project: &Project) -> bool { } #[cfg(unix)] +#[ignore = "requires local socket bind permission"] #[tokio::test] async fn open_binds_the_project_loopback_endpoint() { let state = AppState::build(temp_path("appdata")); @@ -259,6 +260,7 @@ async fn open_binds_the_project_loopback_endpoint() { } #[cfg(unix)] +#[ignore = "requires local socket bind permission"] #[tokio::test] async fn double_open_keeps_a_single_endpoint_no_address_in_use() { let state = AppState::build(temp_path("appdata")); @@ -280,6 +282,7 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() { } #[cfg(unix)] +#[ignore = "requires local socket bind permission"] #[tokio::test] async fn close_cleans_up_the_endpoint_socket_file() { let state = AppState::build(temp_path("appdata")); @@ -311,6 +314,7 @@ fn endpoint_is_deterministic_and_collision_free_across_projects() { } #[cfg(unix)] +#[ignore = "requires local socket bind permission"] #[tokio::test] async fn file_watcher_and_loopback_endpoint_live_together() { let state = AppState::build(temp_path("appdata")); diff --git a/crates/backend/Cargo.toml b/crates/backend/Cargo.toml new file mode 100644 index 0000000..73e0889 --- /dev/null +++ b/crates/backend/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "backend" +version = "0.3.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "IdeA shared backend composition root, reusable by desktop and server adapters." + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +infrastructure = { workspace = true } +tokio = { workspace = true, features = ["io-std", "rt"] } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } +interprocess = { version = "2.4", features = ["tokio"] } + +[features] +vector-http = ["infrastructure/vector-http"] +vector-onnx = ["infrastructure/vector-onnx"] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs new file mode 100644 index 0000000..69345a2 --- /dev/null +++ b/crates/backend/src/lib.rs @@ -0,0 +1,6399 @@ +//! Shared backend core: the product of the transport-neutral composition root. +//! +//! The composition root ([`BackendCore::build`]) is the *single* place that +//! constructs concrete adapters (`new ConcreteAdapter`) and injects them as +//! `Arc` into the use cases (ARCHITECTURE §1.1, §10). The use cases +//! are then exposed to driving adapters through `BackendCore`. + +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::path::Path; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use application::{ + AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, + AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, + ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, + CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, + CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, + CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, + DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, + DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, + ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, + SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, + SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, + StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, + UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, +}; +use async_trait::async_trait; +use domain::ports::{ + AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, + AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, + BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, + EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, + IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, + ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, + StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, + WindowStateStore, +}; +use domain::profile::{ + AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, +}; +use domain::remote::RemoteKind; +use domain::{ + AgentId, AgentInbox, BackgroundTask, BackgroundTaskWakePolicy, DomainEvent, EmbedderProfile, + InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId, + TaskId, TicketId, +}; +use serde_json::{json, Map, Value}; +use uuid::Uuid; + +use infrastructure::{ + embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, + BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, + CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, + FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, + FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, + MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, + TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, + VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, + VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, +}; + +pub mod mcp_endpoint; +pub mod openai_tools; + +use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; +use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; + +use infrastructure::StdioTransport; + +use interprocess::local_socket::tokio::Listener as LocalSocketListener; +use interprocess::local_socket::traits::tokio::Listener as _; +use interprocess::local_socket::{GenericFilePath, ListenerOptions, ToFsName}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +fn spawn_detached(future: impl std::future::Future + Send + 'static) { + std::thread::spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + runtime.block_on(future); + }); +} + +#[cfg(test)] +mod backend_core_build_tests { + use super::BackendCore; + + #[test] + fn backend_core_builds_without_desktop_runtime() { + let dir = std::env::temp_dir().join(format!( + "idea-backend-core-build-{}", + uuid::Uuid::new_v4() + )); + let core = BackendCore::build(dir.clone()); + + assert!(!core.home_dir.is_empty()); + + let _ = std::fs::remove_dir_all(dir); + } +} + +#[derive(Clone, Default)] +pub struct LateBoundTicketToolProvider { + inner: Arc>>>, +} + +impl LateBoundTicketToolProvider { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn bind(&self, inner: Arc) { + *self.inner.lock().expect("ticket tool mutex poisoned") = Some(inner); + } +} + +#[async_trait] +impl TicketToolProvider for LateBoundTicketToolProvider { + async fn handle_ticket_tool( + &self, + project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + let inner = self + .inner + .lock() + .expect("ticket tool mutex poisoned") + .clone() + .ok_or_else(|| { + infrastructure::TicketToolError::new( + "UNAVAILABLE", + "ticket tool provider not bound".to_owned(), + ) + })?; + inner + .handle_ticket_tool(project, requester, name, arguments) + .await + } +} + +/// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`] +/// ciblant le **project root** du tour en cours. +/// +/// L'[`OrchestratorService`] est unique pour tous les projets, alors que le log/handoff +/// conversationnel est **par project root** (`/.ideai/conversations/`). Les +/// adapters `Fs*` fixent leur racine à la construction et ne sont que des jointures de +/// chemin : on en construit donc un jeu frais par tour, ciblant le bon dossier. Sans +/// état (zéro champ), partagé via un simple `Arc`. +struct AppRecordTurnProvider; + +impl RecordTurnProvider for AppRecordTurnProvider { + fn record_turn_for(&self, root: &domain::project::ProjectPath) -> Option> { + let log = Arc::new(FsConversationLog::new(root)); + let handoffs = Arc::new(FsHandoffStore::new(root)); + let summarizer = Arc::new(HeuristicHandoffSummarizer::new()); + Some(Arc::new(RecordTurn::new(log, handoffs, summarizer))) + } +} + +/// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider) +/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi +/// [`domain::ConversationArchive`]) ciblant le **project root** courant. +/// +/// Même raison d'être que [`AppRecordTurnProvider`] : les use cases d'archivage/pagination +/// sont uniques pour tous les projets, alors que les logs sont **par project root**. On +/// construit donc un archive frais par appel, ciblant le bon dossier. Sans état. +struct AppConversationArchiveProvider; + +impl application::ConversationArchiveProvider for AppConversationArchiveProvider { + fn conversation_archive_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsConversationLog::new(root)) as Arc) + } +} + +/// Implémente [`HandoffProvider`](application::HandoffProvider) (lot P7) en +/// matérialisant un [`FsHandoffStore`] ciblant le **project root** du lancement en +/// cours. +/// +/// Même raison d'être que [`AppRecordTurnProvider`] : [`LaunchAgent`] est unique pour +/// tous les projets, alors que le handoff est **par project root** +/// (`/.ideai/conversations/`). On construit donc un store frais par lancement, +/// ciblant le bon dossier. Sans état (zéro champ), partagé via un simple `Arc`. +struct AppHandoffProvider; + +impl application::HandoffProvider for AppHandoffProvider { + fn handoff_store_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsHandoffStore::new(root)) as Arc) + } +} + +/// Implémente [`ConversationLogProvider`](application::ConversationLogProvider) (Lot C) +/// en matérialisant un [`FsConversationLog`] ciblant le **project root** courant. +/// +/// Jumeau stateless de [`AppHandoffProvider`] : le read-model work-state est unique +/// pour tous les projets, alors que le log est **par project root** +/// (`/.ideai/conversations/`). On construit donc un log frais par appel, ciblant +/// le bon dossier. Sert de **repli** lecture seule (`last(_, 3)`) aux résumés de +/// conversation. Sans état (zéro champ), partagé via un simple `Arc`. +struct AppConversationLogProvider; + +impl application::ConversationLogProvider for AppConversationLogProvider { + fn conversation_log_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsConversationLog::new(root)) as Arc) + } +} + +/// Implémente [`LiveStateProvider`] (programme live-state, lot LS3) en matérialisant un +/// [`UpdateLiveState`] dont le [`FsLiveStateStore`] cible le **project root** du tour. +/// +/// Même raison d'être que [`AppRecordTurnProvider`] : l'[`OrchestratorService`] est +/// unique pour tous les projets, alors que le live-state est **par project root** +/// (`/.ideai/live-state.json`) et le store fixe sa racine à la construction. On +/// construit donc un `UpdateLiveState` frais par transition, ciblant le bon dossier. +/// Porte l'horloge (port [`Clock`]) injectée au composition root — `UpdateLiveState` +/// l'utilise pour estampiller `updated_at_ms`. +struct AppLiveStateProvider { + clock: Arc, +} + +impl LiveStateProvider for AppLiveStateProvider { + fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option> { + let store = Arc::new(FsLiveStateStore::new(root)); + Some(Arc::new(UpdateLiveState::new( + store, + Arc::clone(&self.clock), + ))) + } +} + +/// Implémente [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et** +/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un +/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant. +/// +/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et +/// l'[`OrchestratorService`] sont uniques pour tous les projets, alors que le live-state +/// est **par project root** (`/.ideai/live-state.json`) et le store fixe sa racine +/// à la construction. On construit donc un `GetLiveStateLean` frais par appel, ciblant le +/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]). +struct AppLiveStateLeanProvider { + clock: Arc, +} + +impl AppLiveStateLeanProvider { + fn getter(&self, root: &domain::project::ProjectPath) -> Arc { + let store = Arc::new(FsLiveStateStore::new(root)); + Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock))) + } +} + +impl LiveStateLeanProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + +impl LiveStateReadProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + +/// Provider par-root de la réconciliation du live-state au reboot (jumeau de +/// [`AppLiveStateProvider`] côté système). Résout le **project root** depuis le +/// `project_id` via le [`ProjectStore`], matérialise un [`FsLiveStateStore`] ciblant +/// `/.ideai/live-state.json`, puis exécute le use case applicatif +/// [`ReconcileLiveState`] (qui croise chaque ligne avec la liveness réelle de +/// [`LiveSessions`] et ré-upsert les fantômes en `idle`). +/// +/// **Acte système** : appelle le port [`domain::ports::LiveStateStore`] +/// **directement**, jamais via `OrchestratorCommand::SetWorkState` ⇒ le self-only de +/// `idea_workstate_set` reste préservé par construction. Best-effort : le call site +/// (`open_project`) ignore le résultat. +pub struct AppReconcileLiveState { + projects: Arc, + registry: Arc, + clock: Arc, +} + +impl AppReconcileLiveState { + /// Résout la racine du projet, lie le store par-root et lance la réconciliation. + /// + /// # Errors + /// [`AppError`] si le projet est inconnu (registre) ou si le store échoue. + pub async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> { + let project = self.projects.load_project(input.project_id).await?; + let store = Arc::new(FsLiveStateStore::new(&project.root)); + let uc = ReconcileLiveState::new( + store, + Arc::clone(&self.registry) as Arc, + Arc::clone(&self.clock), + ); + uc.execute(input).await + } +} + +/// Store global injecté à l'[`OrchestratorService`] qui route chaque opération vers +/// un [`FsBackgroundTaskStore`] lié au root du projet concerné. +/// +/// Le service orchestrateur est unique et multi-projets, alors que +/// `FsBackgroundTaskStore` fixe `/.ideai/background-tasks` à la construction. +/// Ce routeur garde donc la frontière per-root sans instancier un store global sur +/// une racine arbitraire. +struct AppBackgroundTaskStore { + projects: Arc, + stores: Mutex>>, + task_projects: Mutex>, +} + +impl AppBackgroundTaskStore { + fn new(projects: Arc) -> Self { + Self { + projects, + stores: Mutex::new(HashMap::new()), + task_projects: Mutex::new(HashMap::new()), + } + } + + async fn store_for_project( + &self, + project_id: ProjectId, + ) -> Result, BackgroundTaskPortError> { + if let Some(store) = self + .stores + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&project_id) + .cloned() + { + return Ok(store); + } + + let project = self + .projects + .load_project(project_id) + .await + .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?; + let store = Arc::new(FsBackgroundTaskStore::new(&project.root)); + self.stores + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(project_id, Arc::clone(&store)); + Ok(store) + } + + fn remember(&self, task_id: TaskId, project_id: ProjectId) { + self.task_projects + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(task_id, project_id); + } + + async fn project_for_task( + &self, + task_id: TaskId, + ) -> Result, BackgroundTaskPortError> { + if let Some(project_id) = self + .task_projects + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&task_id) + .copied() + { + return Ok(Some(project_id)); + } + + for project in self + .projects + .list_projects() + .await + .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? + { + let store = self.store_for_project(project.id).await?; + if store.get(task_id).await?.is_some() { + self.remember(task_id, project.id); + return Ok(Some(project.id)); + } + } + Ok(None) + } + + async fn list_undelivered_for_project( + &self, + project_id: ProjectId, + ) -> Result, BackgroundTaskPortError> { + let store = self.store_for_project(project_id).await?; + let tasks = store.list_undelivered_completions().await?; + for task in &tasks { + self.remember(task.id, task.project_id); + } + Ok(tasks) + } + + async fn reconcile_project_boot( + &self, + project_id: ProjectId, + live_task_ids: &[TaskId], + now_ms: u64, + ) -> Result<(), BackgroundTaskPortError> { + let store = self.store_for_project(project_id).await?; + let report = store.reconcile_boot(live_task_ids, now_ms).await?; + for task_id in report + .failed_task_ids + .into_iter() + .chain(report.delivery_pending_task_ids.into_iter()) + { + self.remember(task_id, project_id); + } + Ok(()) + } +} + +#[async_trait] +impl BackgroundTaskStore for AppBackgroundTaskStore { + async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + let store = self.store_for_project(task.project_id).await?; + store.create(task).await?; + self.remember(task.id, task.project_id); + Ok(()) + } + + async fn get(&self, id: TaskId) -> Result, BackgroundTaskPortError> { + let Some(project_id) = self.project_for_task(id).await? else { + return Ok(None); + }; + let task = self.store_for_project(project_id).await?.get(id).await?; + if let Some(task) = &task { + self.remember(task.id, task.project_id); + } + Ok(task) + } + + async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + let store = self.store_for_project(task.project_id).await?; + store.save(task).await?; + self.remember(task.id, task.project_id); + Ok(()) + } + + async fn list_open_for_agent( + &self, + agent_id: AgentId, + ) -> Result, BackgroundTaskPortError> { + let mut tasks = Vec::new(); + for project in self + .projects + .list_projects() + .await + .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? + { + let store = self.store_for_project(project.id).await?; + tasks.extend(store.list_open_for_agent(agent_id).await?); + } + for task in &tasks { + self.remember(task.id, task.project_id); + } + tasks.sort_by_key(|task| (task.created_at_ms, task.id)); + Ok(tasks) + } + + async fn list_undelivered_completions( + &self, + ) -> Result, BackgroundTaskPortError> { + let mut tasks = Vec::new(); + for project in self + .projects + .list_projects() + .await + .map_err(|err| BackgroundTaskPortError::Store(err.to_string()))? + { + tasks.extend(self.list_undelivered_for_project(project.id).await?); + } + tasks.sort_by_key(|task| (task.updated_at_ms, task.id)); + Ok(tasks) + } + + async fn mark_completion_delivered( + &self, + task_id: TaskId, + ) -> Result<(), BackgroundTaskPortError> { + let Some(project_id) = self.project_for_task(task_id).await? else { + return Err(BackgroundTaskPortError::NotFound); + }; + self.store_for_project(project_id) + .await? + .mark_completion_delivered(task_id) + .await + } +} + +pub struct AppReconcileBackgroundTasks { + projects: Arc, + store: Arc, + clock: Arc, + ready: tokio::sync::mpsc::UnboundedSender, +} + +impl AppReconcileBackgroundTasks { + pub async fn execute(&self, project_id: ProjectId) -> Result<(), AppError> { + let project = self.projects.load_project(project_id).await?; + self.store + .reconcile_project_boot(project_id, &[], self.clock.now_millis().max(0) as u64) + .await + .map_err(|err| AppError::Store(err.to_string()))?; + + let tasks = self + .store + .list_undelivered_for_project(project_id) + .await + .map_err(|err| AppError::Store(err.to_string()))?; + for task in tasks { + match task.wake_policy { + BackgroundTaskWakePolicy::WakeOwner => { + let ready = BackgroundTaskReadyToDeliver { + task_id: task.id, + project_id: task.project_id, + owner_agent_id: task.owner_agent_id, + }; + if self.ready.send(ready).is_err() { + return Err(AppError::Internal( + "background task delivery channel is closed".to_owned(), + )); + } + } + BackgroundTaskWakePolicy::RecordOnly => { + self.store + .mark_completion_delivered(task.id) + .await + .map_err(|err| AppError::Store(err.to_string()))?; + } + } + } + + application::diag!( + "[background-task] boot reconcile complete: project={} root={}", + project.id, + project.root.as_str() + ); + Ok(()) + } +} + +fn schedule_background_ready_retry( + ready_tx: tokio::sync::mpsc::UnboundedSender, + ready: BackgroundTaskReadyToDeliver, +) { + spawn_detached(async move { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let _ = ready_tx.send(ready); + }); +} + +fn schedule_background_wake_retry( + wake: Arc, + project: Project, + owner_agent_id: AgentId, + task_id: TaskId, +) { + spawn_detached(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + match wake + .wake_agent( + &project, + owner_agent_id, + WakeReason::BackgroundCompletion { task_id }, + ) + .await + { + Ok(()) => break, + Err(WakeError::AgentBusy { .. }) => continue, + Err(err) => { + application::diag!( + "[background-task] completion wake retry failed: task={} owner={} err={err}", + task_id, + owner_agent_id + ); + break; + } + } + } + }); +} + +struct AppWakeSessionProvider { + launch_agent: Arc, + structured_sessions: Arc, +} + +#[async_trait] +impl WakeSessionProvider for AppWakeSessionProvider { + async fn session_for_wake( + &self, + project: &Project, + agent: AgentId, + ) -> Result, WakeError> { + if let Some(session) = self.structured_sessions.session_for_agent(&agent) { + return Ok(session); + } + + self.launch_agent + .execute(LaunchAgentInput { + project: project.clone(), + agent_id: agent, + rows: 24, + cols: 80, + node_id: None, + conversation_id: None, + mcp_runtime: None, + allow_structured_alongside_pty: true, + }) + .await + .map_err(|err| WakeError::Session(err.to_string()))?; + + self.structured_sessions + .session_for_agent(&agent) + .ok_or_else(|| { + WakeError::Session(format!( + "agent {agent} has no structured session after background wake launch" + )) + }) + } +} + +/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot +/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du +/// lancement en cours. +/// +/// Jumeau stateless de [`AppHandoffProvider`] : [`LaunchAgent`] est unique pour tous +/// les projets, alors que `providers.json` est **par project root** +/// (`/.ideai/conversations/providers.json`). On construit donc un store frais +/// par lancement, ciblant le bon dossier. Sans état (zéro champ), partagé via `Arc`. +struct AppProviderSessionProvider; + +impl application::ProviderSessionProvider for AppProviderSessionProvider { + fn provider_session_store_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(Arc::new(FsProviderSessionStore::new(root)) as Arc) + } +} + +/// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5). +/// +/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le +/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige. +/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce +/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour +/// recomposer un lancement complet. +#[derive(Clone)] +pub struct ResumeContext { + /// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`). + pub project: Project, + /// Hauteur de la cellule au dernier lancement (lignes PTY). + pub rows: u16, + /// Largeur de la cellule au dernier lancement (colonnes PTY). + pub cols: u16, +} + +/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande +/// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé +/// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement. +pub type ResumeContexts = Arc>>; + +/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de +/// lancement existant ([`LaunchAgent`]). +/// +/// À l'échéance d'une limite de session, [`SessionLimitService::execute_resume`] +/// délègue ici : on relit le [`ResumeContext`] alimenté par `launch_agent`, on +/// recompose un [`LaunchAgentInput`] (avec le `conversation_id` de la cellule ⇒ +/// `LaunchAgent` applique [`domain::ports::SessionPlan::Resume`]), puis on transmet le +/// `resume_prompt` comme **premier tour** via le portail d'entrée unique +/// ([`InputMediator`](domain::input::InputMediator)) — jamais un write PTY brut +/// (ARCHITECTURE §20). Sans contexte connu (resume « à l'aveugle »), on échoue +/// proprement (l'erreur empêche `AgentResumed` d'être publié), jamais de panique. +struct AppAgentResumer { + /// Le **même** `Arc` que la commande `launch_agent` (relance/réattache). + launch_agent: Arc, + /// Portail d'entrée unique : injecte le prompt de reprise comme premier tour. + input_mediator: Arc, + /// Contexte de relance alimenté par la commande `launch_agent`. + contexts: ResumeContexts, +} + +#[async_trait::async_trait] +impl AgentResumer for AppAgentResumer { + async fn resume( + &self, + agent_id: AgentId, + node_id: domain::NodeId, + conversation_id: Option, + resume_prompt: &str, + ) -> Result<(), AppError> { + // Repli propre (jamais de panique) : sans contexte de relance connu, on ne + // reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié. + let ctx = self + .contexts + .lock() + .ok() + .and_then(|m| m.get(&agent_id).cloned()); + let Some(ctx) = ctx else { + return Err(AppError::NotFound(format!( + "resume context for agent {agent_id}" + ))); + }; + + // Recompose la déclaration MCP réelle (même recette que la commande + // `launch_agent`) pour que l'agent repris retrouve ses outils `idea_*`. + let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&ctx.project.id) + .as_cli_arg() + .to_owned(), + project_id: ctx.project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + + self.launch_agent + .execute(LaunchAgentInput { + project: ctx.project, + agent_id, + rows: ctx.rows, + cols: ctx.cols, + node_id: Some(node_id), + conversation_id, + mcp_runtime, + allow_structured_alongside_pty: false, + }) + .await?; + + // Premier tour de reprise par le **portail d'entrée** (pas de write brut, §20) : + // l'enqueue publie `DelegationReady`, la cellule l'écrit quand le prompt est prêt. + // On ne corrèle aucune réponse (reprise, pas une délégation) ⇒ on lâche le + // `PendingReply`. + let ticket = domain::mailbox::Ticket::new( + domain::mailbox::TicketId::new_random(), + "IdeA", + resume_prompt, + ); + let _ = self.input_mediator.enqueue(agent_id, ticket); + + Ok(()) + } +} + +/// Everything a driving adapter needs at runtime. +/// +/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete +/// adapters are owned here and never leak past the composition root as concrete +/// types — downstream code only sees the `Arc` held inside the use +/// cases. +pub struct BackendCore { + /// Trivial health use case validating the end-to-end wiring. + pub health: Arc, + /// Create a project (init `.ideai/`, register it). + pub create_project: Arc, + /// Open a project (load meta + manifest). + pub open_project: Arc, + /// Close a project (persist state). + pub close_project: Arc, + /// Close a tab. + pub close_tab: Arc, + /// List known projects. + pub list_projects: Arc, + /// Read `.ideai/CONTEXT.md`. + pub read_project_context: Arc, + /// Overwrite `.ideai/CONTEXT.md`. + pub update_project_context: Arc, + /// Open a terminal (spawn PTY, register session). + pub open_terminal: Arc, + /// Write keystrokes to a terminal. + pub write_terminal: Arc, + /// Resize a terminal. + pub resize_terminal: Arc, + /// Close a terminal (kill PTY). + pub close_terminal: Arc, + /// Load a project's persisted layout tree. + pub load_layout: Arc, + /// Mutate + persist a project's layout tree. + pub mutate_layout: Arc, + /// List all named layouts for a project (#4). + pub list_layouts: Arc, + /// Create a new named layout (#4). + pub create_layout: Arc, + /// Rename a named layout (#4). + pub rename_layout: Arc, + /// Delete a named layout (#4). + pub delete_layout: Arc, + /// Set the active named layout (#4). + pub set_active_layout: Arc, + /// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5). + pub snapshot_running_agents: Arc, + /// Dé-doublonne, à l'ouverture, les feuilles d'agent en double d'un même + /// agent dans `layouts.json` (R0c). Idempotent : no-op sans doublon. + pub reconcile_layouts: Arc, + /// Réconcilie, à l'ouverture, les lignes de live-state fantômes (agent + /// `working`/`waiting`/`blocked` dont la session n'est plus vivante) : les + /// repasse en `idle`. Acte système best-effort, jamais via la surface MCP. + pub reconcile_live_state: Arc, + /// Réconcilie, à l'ouverture, les tâches de fond persistées : marque les + /// `Running` sans handle vivant comme perdues, ré-enqueue les complétions + /// `WakeOwner` non livrées et ferme les `RecordOnly`. + pub reconcile_background_tasks: Arc, + /// Detect which candidate profiles' CLIs are installed (first-run). + pub detect_profiles: Arc, + /// List configured profiles. + pub list_profiles: Arc, + /// Save (upsert) a profile. + pub save_profile: Arc, + /// Create a new OpenCode profile instance from the canonical seed. + pub clone_opencode_profile_from_seed: Arc, + /// Delete a profile. + pub delete_profile: Arc, + /// Persist the batch of chosen profiles (closes the first run). + pub configure_profiles: Arc, + /// Expose the pre-filled reference catalogue. + pub reference_profiles: Arc, + /// Whether the first-run wizard should show + the reference catalogue. + pub first_run_state: Arc, + /// Ensures local llama.cpp model servers for OpenCode profiles. + pub ensure_local_model_server: Arc, + /// Lists local model server configurations. + pub list_model_servers: Arc, + /// Saves local model server configurations. + pub save_model_server: Arc, + /// Deletes local model server configurations when unused. + pub delete_model_server: Arc, + /// The local PTY adapter, kept port-typed so driving adapters can subscribe + /// output and route it through their own transport bridge. + pub pty_port: Arc, + /// Active-terminal registry shared by the terminal use cases. + pub terminal_sessions: Arc, + /// The domain event bus (also handed to the event relay). + pub event_bus: Arc, + /// Create an issue-backed public ticket. + pub create_issue: Arc, + /// Read an issue-backed public ticket. + pub read_issue: Arc, + /// Delete an issue-backed public ticket. + pub delete_issue: Arc, + /// List issue-backed public tickets. + pub list_issues: Arc, + /// Update an issue-backed public ticket. + pub update_issue: Arc, + /// Read a ticket carnet. + pub read_issue_carnet: Arc, + /// Update a ticket carnet. + pub update_issue_carnet: Arc, + /// Link two public tickets. + pub link_issues: Arc, + /// Unlink public tickets. + pub unlink_issues: Arc, + /// Assign or unassign an agent on a public ticket. + pub assign_issue_agent: Arc, + /// Create a sprint. + pub create_sprint: Arc, + /// List sprints. + pub list_sprints: Arc, + /// Rename a sprint. + pub rename_sprint: Arc, + /// Reorder sprints. + pub reorder_sprints: Arc, + /// Delete a sprint. + pub delete_sprint: Arc, + /// Assign a ticket to a sprint. + pub assign_ticket_to_sprint: Arc, + /// Unassign a ticket from its sprint. + pub unassign_ticket_from_sprint: Arc, + /// MCP provider for the public `idea_ticket_*` tools. + pub(crate) ticket_tool_provider: Arc, + /// Open an ephemeral AI assistant chat bound to one ticket. + pub open_ticket_assistant: Arc, + /// Close an ephemeral AI assistant chat bound to one ticket. + pub close_ticket_assistant: Arc, + /// Shared MCP tool policy registry used by ticket assistant sessions. + pub tool_policy_registry: Arc, + /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec + /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) + /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. + pub structured_sessions: Arc, + // --- Agents (L6) --- + /// Create a project agent from scratch. + pub create_agent: Arc, + /// List a project's agents. + pub list_agents: Arc, + /// Read an agent's Markdown context. + pub read_agent_context: Arc, + /// Overwrite an agent's Markdown context. + pub update_agent_context: Arc, + /// Delete an agent from the manifest. + pub delete_agent: Arc, + /// Launch an agent (spawn PTY, apply injection strategy). + pub launch_agent: Arc, + /// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1). + pub change_agent_profile: Arc, + /// Read-only inventory of a project's resumable agent cells, for the reopen + /// panel (§15.2). + pub list_resumable_agents: Arc, + /// Read-only live/busy state for the project's manifest agents. + pub get_project_work_state: Arc, + /// Human paginated read of a conversation's full transcript (lot LS6). + pub read_conversation_page: Arc, + /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). + pub rotate_conversation_log: Arc, + /// Rebinds an already-running agent's live session to a visible cell (Lot D). + pub attach_live_agent: Arc, + /// Tears down an already-running agent's live session by agent id (Lot D). + pub stop_live_agent: Arc, + /// Best-effort inspection of a conversation (last topic + token indicator) + /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of + /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty + /// details, never an error. + pub inspect_conversation: Arc, + /// Project registry — used by agent commands to resolve a `Project` from an id. + pub project_store: Arc, + /// Read the project permission document. + pub get_project_permissions: Arc, + /// Update project-level default permissions. + pub update_project_permissions: Arc, + /// Update one agent permission override. + pub update_agent_permissions: Arc, + /// Resolve effective permissions for one agent. + pub resolve_agent_permissions: Arc, + // --- Windows (L10) --- + /// Detach a tab into a new OS window (persists the workspace topology). + pub move_tab: Arc, + /// Persist open desktop window state on main-window shutdown. + pub snapshot_open_windows: Arc, + /// Load restorable desktop window state on startup. + pub restore_open_windows: Arc, + // --- Background tasks (B8) --- + /// Spawn a command-backed first-class background task. + pub spawn_background_command: Arc, + /// Cancel a running background task. + pub cancel_background_task: Arc, + /// Retry a terminal command task under a fresh task id. + pub retry_background_task: Arc, + /// Store handle used by `list_background_tasks` to read the task read-model. + pub background_task_store: Arc, + // --- Templates & sync (L7) --- + /// Create a template in the global store. + pub create_template: Arc, + /// Update a template's content (bumps version). + pub update_template: Arc, + /// List all templates in the global store. + pub list_templates: Arc, + /// Delete a template from the global store. + pub delete_template: Arc, + /// Create an agent from a template. + pub create_agent_from_template: Arc, + /// Detect which synchronized agents are behind their template. + pub detect_agent_drift: Arc, + /// Apply a template update to a synchronized agent. + pub sync_agent_with_template: Arc, + // --- Git (L8) --- + /// Report the working-tree status of a repository. + pub git_status: Arc, + /// Stage a path. + pub git_stage: Arc, + /// Unstage a path. + pub git_unstage: Arc, + /// Create a commit. + pub git_commit: Arc, + /// List branches. + pub git_branches: Arc, + /// Check out a branch. + pub git_checkout: Arc, + /// Return the recent commit log. + pub git_log: Arc, + /// Initialise a repository. + pub git_init: Arc, + /// Return the commit graph for all local branches. + pub git_graph: Arc, + // --- Skills (L12) --- + /// Create a skill in a scope's store. + pub create_skill: Arc, + /// Update a skill's content. + pub update_skill: Arc, + /// List skills in a scope. + pub list_skills: Arc, + /// Delete a skill from its scope's store. + pub delete_skill: Arc, + /// Assign a skill to an agent (records a `SkillRef`). + pub assign_skill: Arc, + /// Unassign a skill from an agent. + pub unassign_skill: Arc, + + // --- Memory (LOT A — §14.5.1) --- + /// Create a memory note in the project's store. + pub create_memory: Arc, + /// Replace an existing memory note. + pub update_memory: Arc, + /// List the project's memory notes. + pub list_memories: Arc, + /// Read one memory note by slug. + pub get_memory: Arc, + /// Delete a memory note. + pub delete_memory: Arc, + /// Read the structured `MEMORY.md` index. + pub read_memory_index: Arc, + /// Resolve a note's outgoing `[[slug]]` links. + pub resolve_memory_links: Arc, + /// Recall the most relevant memory entries for a query within a budget + /// (LOT B — §14.5.2). + pub recall_memory: Arc, + // --- Embedder config (LOT C2 — §14.5.3) --- + /// List the configured embedder profiles (`embedder.json`). + pub list_embedder_profiles: Arc, + /// Save (upsert, validating) an embedder profile. + pub save_embedder_profile: Arc, + /// Delete an embedder profile by id. + pub delete_embedder_profile: Arc, + /// Describe the embedding engines available to the configuration UI (catalogue + /// + detected local environment + compiled-in capabilities). + pub describe_embedder_engines: Arc, + // --- Embedder suggestion (LOT C3 — §14.5.5) --- + /// Persist the user's response to the embedder suggestion (`later`/`never`). + pub dismiss_embedder_suggestion: Arc, + // --- Orchestrator (§14.3) --- + /// Dispatches validated orchestrator requests to the agent/skill use cases. + /// Shared by every per-project filesystem watcher. + pub orchestrator_service: Arc, + /// Live orchestrator request watchers, keyed by project. One watcher per open + /// project tails its `.ideai/requests/` tree; dropping the handle stops it. + /// Guarded by a `Mutex` so the open/close commands can register/unregister + /// watchers concurrently. + pub orchestrator_watchers: Mutex>, + /// Live IdeA MCP servers, keyed by project — the **twin** of + /// [`orchestrator_watchers`](Self::orchestrator_watchers). One server per open + /// project is the MCP entry door onto the *same* [`OrchestratorService`] the + /// file watcher feeds; both coexist (Décision 4). Started on open/create and + /// stopped on close, exactly like the watcher, via the same `Mutex`-guarded + /// per-project registry. + pub mcp_servers: Mutex>, + /// Service de gestion des **limites de session** des agents (ARCHITECTURE §21) : + /// détecte → planifie → reprend, et annule. Alimenté par les taps niveau 1 + /// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est + /// annulable via la commande `cancel_resume`. + pub session_limit_service: Arc, + /// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] : + /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour + /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. + pub resume_contexts: ResumeContexts, + /// Observateur de **fin de tour** (backstop no-reply) : lit le transcript on-disk de + /// l'agent (Claude `turn_duration`) et appelle `InputMediator::turn_ended`. Remplace + /// le watcher prompt-ready PTY mort. Armé par agent supporté au lancement. + pub turn_watcher: Arc, + /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du + /// [`turn_watcher`](Self::turn_watcher) à l'armement. + pub turn_watch_input: Arc, + /// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent + /// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter + /// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. + pub turn_watch_handles: Mutex>>, + /// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous + /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que + /// l'inspecteur et le turn-watcher. + pub fs_port: Arc, + /// Répertoire home (`$HOME`) servant à dériver `/.claude/projects/` + /// pour la sonde d'activité du rendez-vous. + pub home_dir: String, + /// Late-bound concrete ticket MCP provider supplied by the driving adapter. + pub ticket_tool_binder: Arc, +} + +impl BackendCore { + /// **Composition root.** Builds all adapters and use cases. + /// + /// `app_data_dir` is the machine-local IDE data directory (ARCHITECTURE + /// §9.2), resolved by the caller and injected here so stores never touch the + /// driving adapter themselves (Dependency Inversion). + /// + /// This is the only function that constructs concrete adapters; every other + /// layer depends on ports. Adapters added in later lots (PTY, git, remote) + /// are wired in here. + #[must_use] + pub fn build(app_data_dir: PathBuf) -> Self { + // --- Concrete adapters (driven adapters) --- + let event_bus = Arc::new(TokioBroadcastEventBus::new()); + let clock = Arc::new(SystemClock::new()); + let ids = Arc::new(UuidGenerator::new()); + let fs = Arc::new(LocalFileSystem::new()); + let store = Arc::new(FsProjectStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); + let window_state_store = Arc::new(FsWindowStateStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); + + // Port-typed handles for injection. + let fs_port = Arc::clone(&fs) as Arc; + let store_port = Arc::clone(&store) as Arc; + let window_state_port = Arc::clone(&window_state_store) as Arc; + let events_port = Arc::clone(&event_bus) as Arc; + + // --- Use cases (ports injected as Arc) --- + let health = Arc::new(HealthUseCase::new( + Arc::clone(&clock) as Arc, + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + )); + + let create_project = Arc::new(CreateProject::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let open_project = Arc::new(OpenProject::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + )); + let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port))); + let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port))); + let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port))); + let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port))); + let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port))); + + // --- PTY adapter + terminal use cases (L3) --- + let pty = Arc::new( + PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()), + ); + let pty_port = Arc::clone(&pty) as Arc; + let terminal_sessions = Arc::new(TerminalSessions::new()); + + // --- Sessions structurées (IA / cellules chat, §17) --- + // Registre jumeau de TerminalSessions + fabrique infra routée par + // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et + // ChangeAgentProfile (shutdown polymorphe au hot-swap). + let structured_sessions = Arc::new(StructuredSessions::new()); + let openai_tool_invoker = Arc::new(LateBoundOpenAiToolInvoker::new()); + let openai_tool_invoker_port = Arc::clone(&openai_tool_invoker) as Arc; + let session_factory = Arc::new( + StructuredSessionFactory::new() + .with_sandbox_enforcer(infrastructure::default_enforcer()) + .with_tool_invoker(openai_tool_invoker_port), + ) as Arc; + + let open_terminal = Arc::new(OpenTerminal::new( + Arc::clone(&pty_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + )); + let write_terminal = Arc::new(WriteToTerminal::new( + Arc::clone(&pty_port), + Arc::clone(&terminal_sessions), + )); + let resize_terminal = Arc::new(ResizeTerminal::new( + Arc::clone(&pty_port), + Arc::clone(&terminal_sessions), + )); + let close_terminal = Arc::new(CloseTerminal::new( + Arc::clone(&pty_port), + Arc::clone(&terminal_sessions), + )); + + // --- Layout use cases (L4 + #4) --- + let load_layout = Arc::new(LoadLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + )); + let mutate_layout = Arc::new(MutateLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + let list_layouts = Arc::new(ListLayouts::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + )); + let create_layout = Arc::new(CreateLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + )); + let rename_layout = Arc::new(RenameLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + let delete_layout = Arc::new(DeleteLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + let set_active_layout = Arc::new(SetActiveLayout::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + // Close-time snapshot of running agents (T5). Shares the SAME live-session + // registry as the terminal/agent use cases, so its liveness check reflects + // the very PTYs the shutdown hook is about to kill — it must run *before*. + let snapshot_running_agents = Arc::new(SnapshotRunningAgents::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&terminal_sessions) as Arc, + )); + + // Twin of the snapshot above, but at *open* time: dé-doublonne les + // `layouts.json` portant plusieurs feuilles sur le même agent (R0c, §3.4 + // « Trou C »). Idempotent : aucun doublon ⇒ aucune écriture. + let reconcile_layouts = Arc::new(ReconcileLayouts::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + )); + + // --- Profiles & AI runtime (L5) --- + // One generic, profile-driven runtime adapter (Open/Closed): it holds the + // process spawner used for detection. The profile store persists + // `profiles.json` in the same machine-local app-data dir as the project + // registry. + let spawner = Arc::new(LocalProcessSpawner::new()); + let spawner_port = Arc::clone(&spawner) as Arc; + let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port))); + let runtime_port = Arc::clone(&runtime) as Arc; + + let profile_store = Arc::new(FsProfileStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let profile_store_port: Arc = + Arc::clone(&profile_store) as Arc; + + let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); + let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); + let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port))); + let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); + let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port))); + let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port))); + let reference_profiles = Arc::new(ReferenceProfiles::new()); + let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port))); + + let model_server_registry = Arc::new(FsModelServerRegistry::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new( + app_data_dir.join("hf-model-artifacts"), + )); + let ensure_local_model_server = Arc::new( + EnsureLocalModelServer::new( + Arc::clone(&model_server_registry) as Arc, + Arc::new(HttpOpenAiCompatibleProbe::default()) + as Arc, + Arc::new(LocalManagedProcess::new()) as Arc, + Arc::new(LlamaCppRuntime::new()) as Arc, + Arc::clone(&fs_port), + Arc::clone(&events_port), + ) + .with_model_artifact_downloader( + model_artifact_downloader as Arc, + ), + ); + let model_server_registry_port = + Arc::clone(&model_server_registry) as Arc; + let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( + &model_server_registry_port, + ))); + let save_model_server = Arc::new(SaveModelServer::new(Arc::clone( + &model_server_registry_port, + ))); + let delete_model_server = Arc::new(DeleteModelServer::new( + Arc::clone(&model_server_registry_port), + Arc::clone(&profile_store_port), + )); + + // --- Agent context store + use cases (L6) --- + let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port))); + let contexts_port = Arc::clone(&contexts) as Arc; + + // --- Public tickets (Issue domain) --- + // One stateless FS store/allocator serves every project; the project root is + // passed on each port call and resolves to `/.ideai/tickets/`. + let issue_store = Arc::new(FsIssueStore::new()); + let issue_store_port = Arc::clone(&issue_store) as Arc; + let issue_allocator = Arc::new(FsIssueNumberAllocator::new()); + let issue_allocator_port = Arc::clone(&issue_allocator) as Arc; + let create_issue = Arc::new(CreateIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&issue_allocator_port), + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let read_issue = Arc::new(ReadIssue::new(Arc::clone(&issue_store_port))); + let delete_issue = Arc::new(DeleteIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&events_port), + )); + let list_issues = Arc::new(ListIssues::new(Arc::clone(&issue_store_port))); + let update_issue = Arc::new(UpdateIssue::new( + Arc::clone(&issue_store_port), + Arc::clone(&contexts_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let read_issue_carnet = Arc::new(ReadIssueCarnet::new(Arc::clone(&issue_store_port))); + let update_issue_carnet = Arc::new(UpdateIssueCarnet::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let link_issues = Arc::new(LinkIssues::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let unlink_issues = Arc::new(UnlinkIssues::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let assign_issue_agent = Arc::new(AssignIssueAgent::new( + Arc::clone(&issue_store_port), + Arc::clone(&contexts_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let sprint_store = Arc::new(FsSprintStore::new()); + let sprint_store_port = Arc::clone(&sprint_store) as Arc; + let create_sprint = Arc::new(CreateSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let list_sprints = Arc::new(ListSprints::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + )); + let rename_sprint = Arc::new(RenameSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let reorder_sprints = Arc::new(ReorderSprints::new( + Arc::clone(&sprint_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let delete_sprint = Arc::new(DeleteSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let assign_ticket_to_sprint = Arc::new(AssignTicketToSprint::new( + Arc::clone(&sprint_store_port), + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new( + Arc::clone(&issue_store_port), + Arc::clone(&clock) as Arc, + Arc::clone(&events_port), + )); + let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new()); + let ticket_tool_provider: Arc = ticket_tool_binder.clone(); + let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); + let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; + let assistant_context_provider = Arc::new(FsAssistantContextStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )) as Arc; + let assistant_environment = Arc::new(TicketAssistantEnvironmentPreparer::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + Arc::clone(&runtime_port), + Arc::new(|project: &Project, requester: &str| { + Some(McpRuntime { + exe: crate::mcp_endpoint::idea_exe_path()?, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: requester.to_owned(), + }) + }), + )) as Arc; + let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( + Arc::clone(&issue_store_port), + Arc::clone(&profile_store_port), + assistant_context_provider, + assistant_environment, + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + Arc::clone(&tool_policy_store), + Arc::clone(&events_port), + )); + let close_ticket_assistant = Arc::new(CloseTicketAssistant::new( + Arc::clone(&structured_sessions), + tool_policy_store, + Arc::clone(&events_port), + )); + + // --- Project permissions (LP1) --- + let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); + let permission_store_port = Arc::clone(&permission_store) as Arc; + + // --- Skill store (L12) --- + // Global skills live in the machine-local app-data dir; project skills are + // resolved per call from each project's `.ideai/` (so one store serves all + // open projects). Shared by the skill use cases and the agent launcher + // (assigned-skill injection into the convention file, §14.2). + let skill_store = Arc::new(FsSkillStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let skill_store_port = Arc::clone(&skill_store) as Arc; + + // Memory store + naïve recall (LOT A/B — §14.5.1/§14.5.4). Built here so the + // recall port can be injected into LaunchAgent below (it composes the project + // memory recall into the convention file at activation); the memory use cases + // are wired further down from these same instances. + let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port))); + let memory_store_port = Arc::clone(&memory_store) as Arc; + // Load the configured embedder profile (mono-profile for now: take the last + // listed, fall back to `none`). A multi-profile selector is a follow-up; the + // `none` default keeps recall strictly naïve and dependency-free. + let embedder_store = Arc::new(FsEmbedderProfileStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let embedder_store_port = Arc::clone(&embedder_store) as Arc; + // `build` may run inside an ambient async runtime (desktop setup, or + // `#[tokio::test]`), so blocking the current thread on a future panics. + // Drive the one-shot load on a dedicated thread with its own runtime. + let embedder_profile = std::thread::scope(|s| { + s.spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + .and_then(|rt| rt.block_on(embedder_store.list()).ok()) + .and_then(|mut v| v.pop()) + }) + .join() + .ok() + .flatten() + }) + .unwrap_or_else(EmbedderProfile::none); + let onnx_cache_dir = app_data_dir.join(ONNX_CACHE_SUBDIR); + let memory_recall_port = build_memory_recall( + Arc::clone(&fs_port), + Arc::clone(&memory_store_port), + &embedder_profile, + &onnx_cache_dir, + ); + + // --- Embedder configuration use cases (LOT C2 — §14.5.3) --- + // CRUD over `embedder.json` (port-typed store, built above) + a read-only + // description of the available engines. The environment probe shares the SAME + // `onnx_cache_dir` the recall uses, so "is this model cached?" stays coherent. + // The static ONNX catalogue and the compiled-capability flags are owned by + // infrastructure and injected here (the application stays infra-free, DIP). + let env_inspector = Arc::new(EmbedderEnvProbe::new( + onnx_cache_dir.clone(), + DEFAULT_OLLAMA_BASE_URL, + )) as Arc; + // Cloned for the suggestion check below (the original is moved into + // `describe_embedder_engines`). Both share the same probe behaviour. + let env_inspector_for_suggestion = Arc::clone(&env_inspector); + let recommended_onnx: Vec = RECOMMENDED_ONNX_MODELS + .iter() + .map(|m| OnnxModelView { + id: m.id.to_owned(), + display_name: m.display_name.to_owned(), + dimension: m.dimension, + approx_size_mb: m.approx_size_mb, + recommended: m.recommended, + }) + .collect(); + let list_embedder_profiles = + Arc::new(ListEmbedderProfiles::new(Arc::clone(&embedder_store_port))); + let save_embedder_profile = + Arc::new(SaveEmbedderProfile::new(Arc::clone(&embedder_store_port))); + let delete_embedder_profile = + Arc::new(DeleteEmbedderProfile::new(Arc::clone(&embedder_store_port))); + let describe_embedder_engines = Arc::new(DescribeEmbedderEngines::new( + env_inspector, + recommended_onnx, + VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, + )); + + // --- Embedder suggestion (LOT C3 — §14.5.5) --- + // Per-project dismissal state (`.ideai/memory/.embedder-prompt.json`) + + // the in-memory "already suggested this session" guard, shared with the + // launcher's best-effort check. The check publishes EmbedderSuggested at + // most once per session per project, only while strategy is `none` and the + // memory has outgrown the recall budget. + let prompt_store = Arc::new(FsEmbedderPromptStore::new(Arc::clone(&fs_port))); + let prompt_store_port = Arc::clone(&prompt_store) as Arc; + let suggested_this_session: SuggestedThisSession = SuggestedThisSession::default(); + let check_embedder_suggestion = Arc::new(CheckEmbedderSuggestion::new( + Arc::clone(&embedder_store_port), + Arc::clone(&memory_store_port), + Arc::clone(&prompt_store_port), + env_inspector_for_suggestion, + Arc::clone(&events_port), + Arc::clone(&suggested_this_session), + AGENT_MEMORY_RECALL_BUDGET, + VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, + )); + let dismiss_embedder_suggestion = Arc::new(DismissEmbedderSuggestion::new(Arc::clone( + &prompt_store_port, + ))); + + let create_agent = Arc::new(CreateAgentFromScratch::new( + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + )); + let list_agents = Arc::new(ListAgents::new(Arc::clone(&contexts_port))); + let read_agent_context = Arc::new(ReadAgentContext::new(Arc::clone(&contexts_port))); + let update_agent_context = Arc::new(UpdateAgentContext::new(Arc::clone(&contexts_port))); + let delete_agent = Arc::new(DeleteAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal + // use cases — indispensable for transport bridges to work correctly. + // + // The human-facing launcher intentionally stays PTY-only: when the user opens an + // agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent + // delegation gets its own launcher below, wired to structured/headless sessions. + + // --- Permission projectors (lot LP3-5) --- + // UN seul registre, source unique de vérité, injecté à l'identique dans + // `LaunchAgent` (projection au lancement) ET `ChangeAgentProfile` (nettoyage des + // fichiers orphelins au swap). Les deux projecteurs concrets vivent dans + // l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`. + let permission_projectors = Arc::new( + PermissionProjectorRegistry::new() + .with(Arc::new(ClaudePermissionProjector) as Arc) + .with(Arc::new(CodexPermissionProjector) as Arc), + ); + + let launch_agent = Arc::new( + LaunchAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&runtime_port), + Arc::clone(&fs_port), + Arc::clone(&pty_port), + Arc::clone(&skill_store_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + ) + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_permission_store(Arc::clone(&permission_store_port)) + // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule + // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), + // son résumé est réinjecté dans le convention file. Best-effort, additif : + // un handoff absent/illisible ⇒ lancement normal sans section. + .with_handoff_provider( + Arc::new(AppHandoffProvider) as Arc + ) + // Resumable moteur par provider (lot P8b) : après un lancement structuré + // exposant un id de session moteur, rangé sous la clé de paire dans + // `/.ideai/conversations/providers.json`. Best-effort, additif : une + // écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture. + .with_provider_session_provider(Arc::new(AppProviderSessionProvider) + as Arc) + // Projection des permissions au (re)lancement (lot LP3-5) : avec le + // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur + // du profil matérialise la config de permission de la CLI dans le run dir. + .with_permission_projectors(Arc::clone(&permission_projectors)) + // Aperçu live-state des autres agents (lot LS4) : section `# État du projet` + // injectée au lancement. Best-effort strict : provider absent / erreur / + // parse ⇒ section omise, jamais d'échec de lancement. + .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)), + ); + + // Inter-agent launcher: same context, memory, permissions and live-state + // injection as the human launcher, but with the structured factory wired. It is + // used only by OrchestratorService::ask_agent when a delegated target must be + // started headlessly; UI launches still go through `launch_agent` above. + let orchestrator_launch_agent = Arc::new( + LaunchAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&runtime_port), + Arc::clone(&fs_port), + Arc::clone(&pty_port), + Arc::clone(&skill_store_port), + Arc::clone(&terminal_sessions), + Arc::clone(&events_port), + Arc::clone(&ids) as Arc, + Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), + ) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_permission_store(Arc::clone(&permission_store_port)) + .with_handoff_provider( + Arc::new(AppHandoffProvider) as Arc + ) + .with_provider_session_provider(Arc::new(AppProviderSessionProvider) + as Arc) + .with_permission_projectors(Arc::clone(&permission_projectors)) + .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)) + .with_structured( + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + ), + ); + + // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ + // profile/project/fs stores, the live-session registry and PTY port, and + // *composes* the launcher above for the in-place relaunch (no duplication). + // Voit aussi le registre structuré pour un « kill » polymorphe (§17.4). + let change_agent_profile = Arc::new( + ChangeAgentProfile::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&terminal_sessions), + Arc::clone(&pty_port), + Arc::clone(&launch_agent), + Arc::clone(&events_port), + ) + .with_structured(Arc::clone(&structured_sessions)) + // Même registre que `LaunchAgent` (lot LP3-5) : au swap cross-profile, on + // nettoie les fichiers `Replace` orphelins de l'ancien profil avant relance. + .with_permission_projectors(Arc::clone(&permission_projectors)), + ); + + // Read-only inventory of resumable agent cells (§15.2). Reuses the shared + // project/fs/context/profile stores already injected above — no new port. + let list_resumable_agents = Arc::new(ListResumableAgents::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + )); + + // --- Conversation inspection (T7) --- + // Best-effort, optional, extensible: a `Vec` of SessionInspectors routed + // by profile. Adding an inspectable CLI = pushing one more adapter here. + // The Claude inspector reads `/.claude/projects//.jsonl`; + // `$HOME` is resolved from the environment (empty string if unset — the + // inspector then simply finds nothing and yields empty details). + let home_dir = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default(); + let inspectors: Vec> = vec![Arc::new( + ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir.clone()), + )]; + // Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`) + // — lit le même `/.claude/projects//` que l'inspecteur, via + // le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`). + let turn_watcher: Arc = + Arc::new(infrastructure::ClaudeTranscriptTurnWatcher::new( + Arc::clone(&fs_port), + home_dir.clone(), + )); + let inspect_conversation = Arc::new(InspectConversation::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + inspectors, + )); + + let project_store = Arc::clone(&store_port); + let get_project_permissions = Arc::new(GetProjectPermissions::new(Arc::clone( + &permission_store_port, + ))); + let update_project_permissions = Arc::new(UpdateProjectPermissions::new(Arc::clone( + &permission_store_port, + ))); + let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone( + &permission_store_port, + ))); + let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( + &permission_store_port, + ))); + + // --- Template store + use cases (L7) --- + let template_store = Arc::new(FsTemplateStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let template_store_port = Arc::clone(&template_store) as Arc; + + let create_template = Arc::new(CreateTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&ids) as Arc, + )); + let update_template = Arc::new(UpdateTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&events_port), + )); + let list_templates = Arc::new(ListTemplates::new(Arc::clone(&template_store_port))); + let delete_template = Arc::new(DeleteTemplate::new(Arc::clone(&template_store_port))); + let create_agent_from_template = Arc::new(CreateAgentFromTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&contexts_port), + Arc::clone(&ids) as Arc, + Arc::clone(&events_port), + )); + let detect_agent_drift = Arc::new(DetectAgentDrift::new( + Arc::clone(&template_store_port), + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + let sync_agent_with_template = Arc::new(SyncAgentWithTemplate::new( + Arc::clone(&template_store_port), + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + + // --- Git adapter + use cases (L8) --- + let git = Arc::new(Git2Repository::new()); + let git_port = Arc::clone(&git) as Arc; + + let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port))); + let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port))); + let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port))); + let git_commit = Arc::new(GitCommit::new( + Arc::clone(&git_port), + Arc::clone(&events_port), + )); + let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port))); + let git_checkout = Arc::new(GitCheckout::new( + Arc::clone(&git_port), + Arc::clone(&events_port), + )); + let git_log = Arc::new(GitLog::new(Arc::clone(&git_port))); + let git_init = Arc::new(GitInit::new( + Arc::clone(&git_port), + Arc::clone(&events_port), + )); + let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port))); + + // --- Skill use cases (L12) --- + // Reuse the skill store (built above for the launcher) and the shared + // agent context store for the agent↔skill assignment. + let create_skill = Arc::new(CreateSkill::new( + Arc::clone(&skill_store_port), + Arc::clone(&ids) as Arc, + )); + let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port))); + let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port))); + let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port))); + // Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` (feature + // « skills à la MCP ») — compose le SkillStore existant, câblé plus bas sur + // l'OrchestratorService via le builder additif `.with_read_skill(...)`. + let read_skill = Arc::new(ReadSkill::new(Arc::clone(&skill_store_port))); + let assign_skill = Arc::new(AssignSkillToAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + let unassign_skill = Arc::new(UnassignSkillFromAgent::new( + Arc::clone(&contexts_port), + Arc::clone(&events_port), + )); + + // --- Memory use cases (LOT A — §14.5.1) --- + // `memory_store` / `memory_store_port` / `memory_recall_port` are built + // earlier (above LaunchAgent, which needs the recall port). A single + // FsMemoryStore takes the project root per call (like the skill store), so + // one instance serves every open project. The mutating use cases share the + // event bus to announce Memory{Saved,Deleted}. + let create_memory = Arc::new(CreateMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let update_memory = Arc::new(UpdateMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let list_memories = Arc::new(ListMemories::new(Arc::clone(&memory_store_port))); + let get_memory = Arc::new(GetMemory::new(Arc::clone(&memory_store_port))); + let delete_memory = Arc::new(DeleteMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port))); + let resolve_memory_links = + Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port))); + // Naïve recall (LOT B): the same instance injected into LaunchAgent above — + // composes the store, returns index entries in order truncated to the token + // budget. The default, dependency-free MemoryRecall; substitutable by a + // VectorMemoryRecall (LOT C). + let recall_memory = Arc::new(RecallMemory::new(Arc::clone(&memory_recall_port))); + + // --- Orchestrator service (§14.3) --- + // Dispatches file-based orchestrator requests through the *same* use cases + // the UI drives (IdeA stays the single source of truth for the agent/skill + // lifecycle). The per-project watcher that feeds it is started lazily when + // a project is opened (see `ensure_orchestrator_watch`). + // File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche + // déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par + // tous les projets (clé interne = AgentId, jamais de collision cross-projet). + // Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de + // corrélation par ticket) + porte la **livraison** du tour dans le PTY de la + // cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est + // partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur. + let inmemory_mailbox = Arc::new(InMemoryMailbox::new()); + let mailbox = Arc::clone(&inmemory_mailbox) as Arc; + // Same concrete mailbox, second (read-only) port view for the work-state + // read model: lists pending tickets without touching the mutating surface. + let queue_snapshot = + Arc::clone(&inmemory_mailbox) as Arc; + let mediated_inbox = Arc::new( + MediatedInbox::with_pty( + Arc::clone(&inmemory_mailbox), + Arc::new(SystemMillisClock), + Arc::clone(&pty_port), + ) + // Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un + // tour, Idle au mark_idle) ⇒ relayé au front en live event (cadrage C4). + .with_events(Arc::clone(&events_port)), + ); + // Détection de stagnation (lot 2, readiness/heartbeat) : une tâche périodique + // détenue au composition root appelle `sweep_stalled` (logique de décision pure, + // `now` fourni par l'horloge injectée). Bascule `Alive→Stalled` les agents `Busy` + // sans battement depuis `stall_after_ms` (profil) et émet `AgentLivenessChanged` + // une fois par transition. Tick d'1 s : largement assez fin pour des seuils en + // dizaines de secondes, négligeable en charge. Détaché ⇒ vit autant que l'app. + { + let sweeper = Arc::clone(&mediated_inbox); + // `build` may run in a desktop setup hook (main thread, no ambient Tokio + // runtime) — `tokio::spawn` would panic with "there is no reactor running". + // Use the core detached spawner instead of assuming an ambient runtime. + spawn_detached(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + tick.tick().await; + sweeper.sweep_stalled(); + } + }); + } + let input_mediator = Arc::clone(&mediated_inbox) as Arc; + let background_tasks = Arc::new(AppBackgroundTaskStore::new(Arc::clone(&store_port))); + let background_tasks_port = Arc::clone(&background_tasks) as Arc; + let (background_ready_tx, mut background_ready_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + // --- B8 : runner de commandes + fermeture de la boucle du sink --- + // Le runner concret exécute les tâches command-backed sur le `PtyPort` composé + // (local ici ; SSH/WSL via `RemoteHost` quand ils atterriront — Liskov). Il + // émet UNE complétion par tâche ; le sink (single-writer) persiste l'état + // terminal AVANT de signaler la livraison (persist-avant-signal), puis le pont + // ready→inbox ci-dessous réveille l'agent propriétaire. + let background_runner = Arc::new(CommandBackgroundRunner::new( + Arc::clone(&pty_port), + Arc::clone(&clock) as Arc, + )); + let background_runner_port = + Arc::clone(&background_runner) as Arc; + { + let sink = Arc::new(BackgroundCompletionSink::new( + Arc::clone(&background_tasks_port), + background_ready_tx.clone(), + )); + let runner = Arc::clone(&background_runner_port); + // `start_from_runner` exige un runtime Tokio ambiant (`Handle::current`) : + // on l'appelle donc DANS la tâche async. Le `JoinHandle` du drain est gardé + // vivant en l'attendant (il ne se termine qu'à la fermeture du flux de + // complétions), ce qui maintient sink + runner en vie pour la session. + spawn_detached(async move { + let drain = sink.start_from_runner(runner); + let _ = drain.await; + }); + } + let spawn_background_command = Arc::new(SpawnBackgroundCommand::new( + Arc::clone(&background_tasks_port), + Arc::clone(&background_runner_port), + Arc::clone(&clock) as Arc, + Arc::clone(&ids) as Arc, + )); + let cancel_background_task = Arc::new(CancelBackgroundTask::new( + Arc::clone(&background_tasks_port), + Arc::clone(&background_runner_port), + )); + let retry_background_task = Arc::new(RetryBackgroundTask::new( + Arc::clone(&background_tasks_port), + Arc::clone(&background_runner) as Arc, + Arc::clone(&spawn_background_command), + )); + let background_wake = Arc::new(AgentWakeService::new( + Arc::clone(&mediated_inbox) as Arc, + Arc::clone(&input_mediator), + Arc::clone(&mailbox), + Arc::clone(&background_tasks_port), + Arc::new(AppWakeSessionProvider { + launch_agent: Arc::clone(&orchestrator_launch_agent), + structured_sessions: Arc::clone(&structured_sessions), + }), + Some(Arc::clone(&events_port)), + )) as Arc; + { + let inbox = Arc::clone(&mediated_inbox); + let wake = Arc::clone(&background_wake); + let projects = Arc::clone(&store_port); + let clock_for_items = Arc::clone(&clock) as Arc; + let retry_ready = background_ready_tx.clone(); + spawn_detached(async move { + while let Some(ready) = background_ready_rx.recv().await { + let item = InboxItem { + id: TicketId::new_random(), + agent_id: ready.owner_agent_id, + source: InboxSource::BackgroundTask { + task_id: ready.task_id, + }, + kind: InboxItemKind::BackgroundCompletion, + body: format!("Background task {} completed.", ready.task_id), + created_at_ms: clock_for_items.now_millis().max(0) as u64, + correlation_id: Some(ready.task_id.to_string()), + }; + match inbox.enqueue_message(ready.owner_agent_id, item) { + Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => { + application::diag!( + "[background-task] completion deferred: task={} owner={} \ + queue_depth={}", + ready.task_id, + ready.owner_agent_id, + receipt.depth + ); + schedule_background_ready_retry(retry_ready.clone(), ready); + continue; + } + Ok(_) => {} + Err(InboxError::InboxFull { .. }) => { + application::diag!( + "[background-task] completion inbox full but durable: task={} \ + owner={}", + ready.task_id, + ready.owner_agent_id + ); + schedule_background_ready_retry(retry_ready.clone(), ready); + continue; + } + Err(err) => { + application::diag!( + "[background-task] completion inbox enqueue failed: task={} \ + owner={} err={err}", + ready.task_id, + ready.owner_agent_id + ); + continue; + } + } + + let project = match projects.load_project(ready.project_id).await { + Ok(project) => project, + Err(err) => { + application::diag!( + "[background-task] completion wake skipped: project={} task={} \ + owner={} err={err}", + ready.project_id, + ready.task_id, + ready.owner_agent_id + ); + continue; + } + }; + if let Err(err) = wake + .wake_agent( + &project, + ready.owner_agent_id, + WakeReason::BackgroundCompletion { + task_id: ready.task_id, + }, + ) + .await + { + if matches!(err, WakeError::AgentBusy { .. }) { + application::diag!( + "[background-task] completion wake postponed: task={} owner={}", + ready.task_id, + ready.owner_agent_id + ); + schedule_background_wake_retry( + Arc::clone(&wake), + project, + ready.owner_agent_id, + ready.task_id, + ); + continue; + } + application::diag!( + "[background-task] completion wake failed: task={} owner={} err={err}", + ready.task_id, + ready.owner_agent_id + ); + } + } + }); + } + let live_sessions = Arc::new(LiveSessions::new( + Arc::clone(&terminal_sessions), + Arc::clone(&structured_sessions), + )); + // Réconciliation du live-state au reboot : repasse en `idle` les lignes + // fantômes (working/waiting/blocked) dont la session n'est plus vivante, + // selon le MÊME registre de liveness que `GetProjectWorkState`. Provider + // par root (le store fixe sa racine à la construction). Câblé dans + // `open_project`, best-effort (cf. `reconcile_layouts`). + let reconcile_live_state = Arc::new(AppReconcileLiveState { + projects: Arc::clone(&store_port), + registry: Arc::clone(&live_sessions), + clock: Arc::clone(&clock) as Arc, + }); + let reconcile_background_tasks = Arc::new(AppReconcileBackgroundTasks { + projects: Arc::clone(&store_port), + store: Arc::clone(&background_tasks), + clock: Arc::clone(&clock) as Arc, + ready: background_ready_tx, + }); + // Lot C — résumés de conversation best-effort : on câble les sources par + // project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule, + // aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets. + let get_project_work_state = Arc::new( + GetProjectWorkState::new( + Arc::clone(&contexts_port), + Arc::clone(&live_sessions), + Arc::clone(&input_mediator), + queue_snapshot, + ) + .with_conversation_sources( + Arc::new(AppHandoffProvider) as Arc, + Arc::new(AppConversationLogProvider) + as Arc, + ) + .with_background_tasks(Arc::clone(&background_tasks_port)), + ); + // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux + // composent le provider d'archive par root ; la rotation lit aussi le handoff + // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`. + let archive_provider = Arc::new(AppConversationArchiveProvider) + as Arc; + let read_conversation_page = + Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider))); + let rotate_conversation_log = Arc::new(RotateConversationLog::new( + Arc::clone(&archive_provider), + Arc::new(AppHandoffProvider) as Arc, + )); + // Lot D — actions contrôlées agent-level sur les sessions vivantes : attach + // (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive + // `CloseTerminal` existante / shutdown structuré). Aucune création de session. + let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions))); + + // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- + // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : + // l'horloge système, le bus partagé, un `TokioScheduler` (minuterie one-shot + // annulable) dont les tâches échues sont drainées plus bas, et un `AppAgentResumer` + // qui relance via le *même* `LaunchAgent` que la commande `launch_agent`. + let resume_contexts: ResumeContexts = Arc::new(Mutex::new(HashMap::new())); + let (resume_tx, mut resume_rx) = tokio::sync::mpsc::unbounded_channel::(); + let scheduler = Arc::new(TokioScheduler::new( + resume_tx, + Arc::clone(&clock) as Arc, + )) as Arc; + let resumer = Arc::new(AppAgentResumer { + launch_agent: Arc::clone(&launch_agent), + input_mediator: Arc::clone(&input_mediator), + contexts: Arc::clone(&resume_contexts), + }) as Arc; + let session_limit_service = Arc::new(SessionLimitService::new( + Arc::clone(&clock) as Arc, + scheduler, + Arc::clone(&events_port), + resumer, + )); + // Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une + // `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed). + // Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build` + // peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app. + { + let service = Arc::clone(&session_limit_service); + spawn_detached(async move { + while let Some(task) = resume_rx.recv().await { + if let Err(e) = service.execute_resume(task).await { + // Best-effort : une relance qui échoue ne fige pas le drain. + eprintln!("[session-limit] reprise auto échouée : {e}"); + } + } + }); + } + + // Registre des conversations par paire (cadrage C3) : un fil par paire, session + // vivante keyée par conversation (lève l'ambiguïté session/agent). + let conversation_registry = Arc::new(InMemoryConversationRegistry::new()) + as Arc; + // Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en + // `Arc` puis cloné dans les quatre use cases, pour que les leases + // se coordonnent entre eux et que l'invariant single-writer du contexte projet tienne. + let file_guard = Arc::new(RwFileGuard::new()) as Arc; + let context_guard = Arc::new(ContextGuardUseCases { + read_context: Arc::new(ReadContext::new( + Arc::clone(&file_guard), + Arc::clone(&contexts_port), + Arc::clone(&fs_port), + )), + propose_context: Arc::new(ProposeContext::new( + Arc::clone(&file_guard), + Arc::clone(&contexts_port), + Arc::clone(&fs_port), + Arc::clone(&clock) as Arc, + )), + read_memory: Arc::new(ReadMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory_store_port), + )), + write_memory: Arc::new(WriteMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory_store_port), + )), + }); + let orchestrator_service = Arc::new( + OrchestratorService::new( + Arc::clone(&create_agent), + Arc::clone(&orchestrator_launch_agent), + Arc::clone(&list_agents), + Arc::clone(&close_terminal), + Arc::clone(&update_agent_context), + Arc::clone(&create_skill), + Arc::clone(&profile_store_port), + Arc::clone(&terminal_sessions), + ) + // Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison + // PTV sérialisée) + registre de conversations + bus pour AgentReplied. + .with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox)) + .with_conversations(Arc::clone(&conversation_registry)) + .with_events(Arc::clone(&events_port)) + // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour + // que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP + // réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip. + .with_mcp_runtime_provider( + Arc::new(AppMcpRuntimeProvider) as Arc + ) + // Persistance conversationnelle best-effort (lot P6b) : Prompt + Response de + // chaque paire déléguée écrits dans `/.ideai/conversations/` via + // le provider per-root + l'horloge millis (port Clock) déjà câblée. Un échec + // n'affecte jamais la délégation live. + .with_record_turn( + Arc::new(AppRecordTurnProvider) as Arc, + Arc::clone(&clock) as Arc, + ) + // FileGuard context/memory (cadrage C7) : branche les quatre use cases + // ReadContext/ProposeContext/ReadMemory/WriteMemory derrière le garde partagé. + // Sans ça, `require_context_guard()` reste `None` ⇒ les outils MCP + // `idea_context_*`/`idea_memory_*` échouent pour tous les agents. + .with_context_guard(context_guard) + // Auto-memory harvest (Lot E1) : après le checkpoint Response d'un `ask` + // réussi, parse les blocs ` ```idea-memory ` de la réponse et persiste les + // notes valides via le `MemoryStore` (root par appel). Best-effort strict : + // un échec n'altère jamais la réponse/le ticket/le handoff. + .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + ))) + // Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça, + // `skill.read` renverrait « not configured ». Compose le SkillStore existant. + .with_read_skill(Arc::clone(&read_skill)) + // Auto-update du live-state (programme live-state, lot LS3) : dérivé des + // transitions de délégation (ask→Working, reply→Done), zéro token agent. + // Provider par root (`/.ideai/live-state.json`) car le port fixe sa + // racine à la construction. Best-effort strict : un échec n'altère jamais la + // délégation. + .with_live_state(Arc::new(AppLiveStateProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc) + // Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot + // lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe + // sa racine à la construction). Sans ça, l'outil renverrait « not configured ». + .with_live_state_read(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc) + // Fenêtre d'inactivité réarmable (signe de vie) du rendez-vous délégué : la + // borne de tour dans le service n'est plus un timeout plat mais une fenêtre + // réarmée à chaque progrès observé de la cible (octets cumulés de son + // transcript Claude), sous le plafond ci-dessous. SANS cette sonde, la borne + // dégrade vers un timeout plat (fallback, zéro régression) et coupe un long + // tour unique à 600 s — c'est précisément la sonde qui rend le fix effectif. + // Keyée par (project_root, agent_id) : le run-dir transcript = `/.ideai/ + // run/`, encodé par Claude sous `/.claude/projects/...`. + .with_ask_liveness_probe({ + let fs = Arc::clone(&fs_port); + let home = home_dir.clone(); + Arc::new(move |root: domain::project::ProjectPath, agent_id| { + let fs = Arc::clone(&fs); + let home = home.clone(); + Box::pin(async move { + let run_dir = format!( + "{}/.ideai/run/{agent_id}", + root.as_str().trim_end_matches(['/', '\\']) + ); + let cwd = domain::project::ProjectPath::new(run_dir).ok()?; + infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await + }) + as std::pin::Pin> + Send>> + }) as application::AskLivenessProbe + }) + // Plafond absolu du rendez-vous délégué (réglage projet via + // `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : la fenêtre réarmée ne parque + // jamais un `ask` au-delà, même contre une cible perpétuellement active. + .with_ask_ceiling(application::resolve_rendezvous_ceiling( + std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()), + )) + // Conversation inter-agent headless : le service voit le même registre que le + // launcher orchestrateur ci-dessus. Une cible à `structured_adapter` est donc + // démarrée/drainée via `AgentSession::send` et son `Final`, sans dépendre de + // `idea_reply`; les autres outils MCP restent câblés par ailleurs. + .with_structured(Arc::clone(&structured_sessions)) + // Tâches de fond de 1re classe (B6/B7) : le port injecté est global mais + // route chaque opération vers un `FsBackgroundTaskStore` lié au root du + // projet concerné. Active notamment le rendez-vous headless tracé comme + // BackgroundTask, sans figer un store unique sur un root arbitraire. + .with_background_tasks( + Arc::clone(&background_tasks_port), + Arc::clone(&clock) as Arc, + ) + // Limites de session sur le chemin délégué headless : quand le drain + // structuré retourne `RateLimited`, l'orchestrateur arme la reprise pour + // la cible limitée via le même service que les chemins directs. + .with_session_limits(Arc::clone(&session_limit_service)) + // Producteur B8 côté agent MCP : `idea_run_in_background` passe par le + // même use case que la commande desktop, sans aller-retour frontend. + .with_spawn_background_command(Arc::clone(&spawn_background_command)), + ); + openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( + Arc::clone(&orchestrator_service), + Arc::clone(&store_port), + )) as Arc); + + let stop_live_agent = Arc::new( + StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) + .with_cascade( + Arc::clone(&input_mediator), + Arc::clone(&orchestrator_service), + ), + ); + + // --- Windows (L10) --- + let move_tab = Arc::new(MoveTabToNewWindow::new( + Arc::clone(&store_port), + Arc::clone(&ids) as Arc, + )); + let snapshot_open_windows = + Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port))); + let restore_open_windows = Arc::new(RestoreOpenWindows::new( + Arc::clone(&window_state_port), + Arc::clone(&store_port), + )); + + Self { + health, + create_project, + open_project, + close_project, + close_tab, + list_projects, + read_project_context, + update_project_context, + open_terminal, + write_terminal, + resize_terminal, + close_terminal, + load_layout, + mutate_layout, + list_layouts, + create_layout, + rename_layout, + delete_layout, + set_active_layout, + snapshot_running_agents, + reconcile_layouts, + reconcile_live_state, + reconcile_background_tasks, + detect_profiles, + list_profiles, + save_profile, + clone_opencode_profile_from_seed, + delete_profile, + configure_profiles, + reference_profiles, + first_run_state, + ensure_local_model_server, + list_model_servers, + save_model_server, + delete_model_server, + pty_port, + terminal_sessions, + event_bus, + create_issue, + read_issue, + delete_issue, + list_issues, + update_issue, + read_issue_carnet, + update_issue_carnet, + link_issues, + unlink_issues, + assign_issue_agent, + create_sprint, + list_sprints, + rename_sprint, + reorder_sprints, + delete_sprint, + assign_ticket_to_sprint, + unassign_ticket_from_sprint, + ticket_tool_provider, + open_ticket_assistant, + close_ticket_assistant, + tool_policy_registry, + structured_sessions, + create_agent, + list_agents, + read_agent_context, + update_agent_context, + delete_agent, + launch_agent, + change_agent_profile, + list_resumable_agents, + get_project_work_state, + read_conversation_page, + rotate_conversation_log, + attach_live_agent, + stop_live_agent, + inspect_conversation, + project_store, + get_project_permissions, + update_project_permissions, + update_agent_permissions, + resolve_agent_permissions, + create_template, + update_template, + list_templates, + delete_template, + create_agent_from_template, + detect_agent_drift, + sync_agent_with_template, + git_status, + git_stage, + git_unstage, + git_commit, + git_branches, + git_checkout, + git_log, + git_init, + git_graph, + create_skill, + update_skill, + list_skills, + delete_skill, + assign_skill, + unassign_skill, + create_memory, + update_memory, + list_memories, + get_memory, + delete_memory, + read_memory_index, + resolve_memory_links, + recall_memory, + list_embedder_profiles, + save_embedder_profile, + delete_embedder_profile, + describe_embedder_engines, + dismiss_embedder_suggestion, + orchestrator_service, + orchestrator_watchers: Mutex::new(HashMap::new()), + mcp_servers: Mutex::new(HashMap::new()), + session_limit_service, + resume_contexts, + turn_watcher, + turn_watch_input: Arc::clone(&input_mediator), + turn_watch_handles: Mutex::new(HashMap::new()), + fs_port: Arc::clone(&fs_port), + home_dir, + move_tab, + snapshot_open_windows, + restore_open_windows, + spawn_background_command, + cancel_background_task, + retry_background_task, + background_task_store: Arc::clone(&background_tasks_port), + ticket_tool_binder, + } + } + + /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already + /// running for it (idempotent). The watcher tails the project's + /// `.ideai/requests/` tree and dispatches each request through the shared + /// [`OrchestratorService`]; processed-request events are republished on the + /// domain event bus so the relay surfaces them to the frontend. + /// + /// Called from the `open_project` / `create_project` commands. Must run inside + /// the Tokio runtime (the watcher spawns a background task) — async driving + /// adapters satisfy this. + pub fn ensure_orchestrator_watch(&self, project: &Project) { + let mut watchers = self + .orchestrator_watchers + .lock() + .expect("orchestrator watcher registry poisoned"); + if watchers.contains_key(&project.id) { + return; + } + let bus = Arc::clone(&self.event_bus); + let events: Arc = + Arc::new(move |event| bus.publish(event)); + let handle = FsOrchestratorWatcher::start( + project.clone(), + Arc::clone(&self.orchestrator_service), + events, + ); + watchers.insert(project.id, handle); + drop(watchers); + + // Start the MCP server for this project, beside (and in parallel with) the + // file watcher — both are entry doors onto the *same* OrchestratorService + // (Décision 4). Idempotent like the watcher above. + self.ensure_mcp_server(project); + } + + /// Starts an [`McpServer`] for `project` if one is not already registered + /// (idempotent), the **twin** of [`ensure_orchestrator_watch`]. Registers its + /// lifecycle handle in [`mcp_servers`](Self::mcp_servers); dropping/stopping the + /// handle tears down the supervision task. + /// + /// ## Transport decision (S-MCP, cadrage v5 §1) & endpoint lifecycle (M5a) + /// + /// At project-open time there is **no CLI connected yet**: a peer only appears + /// once an MCP-capable agent is launched with the injected MCP config (M5d) and + /// its spawned `idea mcp-server` bridge dials this project's loopback endpoint. + /// We therefore do **not** run a blocking `McpServer::serve`/accept loop here + /// (that must never figer the open/close of a project). M5a's job is narrower: + /// **bind the project's loopback endpoint** ([`mcp_endpoint`], the single source + /// of truth) so it is ready when a bridge connects, and **hold** the listener in + /// the handle. The actual accept-and-serve-per-peer is M5b/M5c. + /// + /// Binding is **non-blocking** (create the listener, then park on the stop + /// signal) and **idempotent** (one endpoint per project: a second open returns + /// early without rebinding). On close the handle is dropped, which on Unix + /// unlinks the socket file (interprocess reclaim guard) — no leak. + fn ensure_mcp_server(&self, project: &Project) { + let mut servers = self + .mcp_servers + .lock() + .expect("mcp server registry poisoned"); + if servers.contains_key(&project.id) { + return; + } + let bus = Arc::clone(&self.event_bus); + let events: Arc = + Arc::new(move |event| bus.publish(event)); + let endpoint = mcp_endpoint(&project.id); + let listener = bind_endpoint(&endpoint); + // The project-id string the handshake guard compares against: the same + // hyphen-free hex form the endpoint encodes, which M5d's `--project` reuses. + let project_id = project.id.as_uuid().simple().to_string(); + // Readiness de démarrage : quand le pont MCP d'un agent se connecte (initialize), + // libère son éventuel 1er tour différé (fix race cold-launch via signal MCP). + // L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la + // composition root est la seule à connaître la frontière infra↔domaine). + let service_for_ready = Arc::clone(&self.orchestrator_service); + let ready_sink: Arc = Arc::new(move |requester: &str| { + if let Ok(uuid) = Uuid::parse_str(requester) { + service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); + } + }); + let handle = McpServerHandle::start( + McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) + .with_events(events) + .with_ready_sink(ready_sink) + .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) + .with_tool_policies(Arc::clone(&self.tool_policy_registry)), + endpoint, + listener, + project_id, + ); + servers.insert(project.id, handle); + } + + /// Returns the ids of every currently-open project. + /// + /// Derived from the orchestrator watcher registry, which holds exactly one + /// entry per open project (started on open/create, dropped on close). Used by + /// the shutdown hook to snapshot running agents across all open projects + /// before the global PTY kill. + #[must_use] + pub fn open_project_ids(&self) -> Vec { + self.orchestrator_watchers + .lock() + .map(|w| w.keys().copied().collect()) + .unwrap_or_default() + } + + /// Repairs the persisted Claude run-dir artefacts of `project` so a freshly + /// launched AppImage does not inherit stale MCP/settings state from older runs. + /// + /// Best-effort and local-only: remote SSH/WSL projects are skipped because this + /// migration rewrites the local run-dir files the AppImage owns. Launch-time + /// repair still remains available on the normal activation path. + pub async fn reconcile_claude_run_dirs(&self, project: &Project) { + self.migrate_claude_run_dirs(project).await; + } + + /// Arms the **end-of-turn watcher** (no-reply backstop) for `agent_id` if its + /// `profile` is supported (Claude). The watcher tails the agent's isolated run-dir + /// transcript folder (`/.ideai/run/`) and routes each detected turn end + /// to [`InputMediator::turn_ended`](domain::input::InputMediator::turn_ended). Called + /// at launch; **idempotent by replacement** — a relaunch drops the previous handle + /// (stopping its polling task) and arms a fresh one. A non-supported profile is a + /// no-op. `conversation_id` is diagnostic only. + pub fn arm_turn_watch( + &self, + project_root: &domain::project::ProjectPath, + agent_id: AgentId, + profile: &AgentProfile, + conversation_id: Option, + ) { + if !self.turn_watcher.supports(profile) { + return; + } + // cwd = the agent's isolated run dir (matches the profile `{agentRunDir}` cwd that + // Claude runs in, hence the `/.claude/projects//` folder). + let run_dir = format!( + "{}/.ideai/run/{agent_id}", + project_root.as_str().trim_end_matches(['/', '\\']) + ); + let Ok(cwd) = domain::project::ProjectPath::new(run_dir) else { + return; + }; + let input = Arc::clone(&self.turn_watch_input); + // Callback invoked from the watcher's polling task (no lock held here). + let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a)); + let handle = self + .turn_watcher + .watch(agent_id, conversation_id, cwd, on_turn_end); + if let Ok(mut map) = self.turn_watch_handles.lock() { + // Insert replaces (and drops) any prior handle ⇒ its polling task stops. + map.insert(agent_id, handle); + } + } + + /// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping + /// the stored handle stops its polling task. No-op if none is armed. + pub fn stop_turn_watch(&self, agent_id: AgentId) { + if let Ok(mut map) = self.turn_watch_handles.lock() { + map.remove(&agent_id); + } + } + + /// Stops and removes the orchestrator watcher for `project_id`, if any. + /// Called from `close_project` so a closed project stops consuming requests. + /// Symmetrically stops the project's MCP server (its twin) so both entry doors + /// are torn down together. + pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) { + if let Some(handle) = self + .orchestrator_watchers + .lock() + .expect("orchestrator watcher registry poisoned") + .remove(project_id) + { + handle.stop(); + } + if let Some(handle) = self + .mcp_servers + .lock() + .expect("mcp server registry poisoned") + .remove(project_id) + { + handle.stop(); + } + } + + /// Best-effort migration of persisted Claude run dirs at project-open time: + /// repairs stale `.mcp.json` declarations and missing + /// `enabledMcpjsonServers` entries in `.claude/settings.local.json`. + pub async fn migrate_claude_run_dirs(&self, project: &Project) { + if project.remote.kind() != RemoteKind::Local { + return; + } + let agents = match self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await + { + Ok(output) => output.agents, + Err(_) => return, + }; + let profiles = match self.list_profiles.execute().await { + Ok(output) => output.profiles, + Err(_) => return, + }; + let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect(); + for agent in agents { + let Some(profile) = profile_by_id.get(&agent.profile_id) else { + continue; + }; + // Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex + // (`config.toml` isolé via `CODEX_HOME`). Tout autre profil ⇒ rien à migrer. + if is_claude_mcp_profile(profile) { + let _ = migrate_claude_run_dir(project, &agent.id, profile).await; + } else if is_codex_mcp_profile(profile) { + let _ = migrate_codex_run_dir(project, &agent.id, profile).await; + } + } + } +} + +async fn migrate_claude_run_dir( + project: &Project, + agent_id: &AgentId, + profile: &AgentProfile, +) -> Result<(), std::io::Error> { + let run_dir = project + .root + .as_str() + .trim_end_matches(['/', '\\']) + .to_owned() + + &format!("/.ideai/run/{agent_id}"); + let run_dir_path = Path::new(&run_dir); + if tokio::fs::metadata(run_dir_path).await.is_err() { + return Ok(()); + } + + migrate_claude_settings(run_dir_path, project.root.as_str()).await?; + + let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?; + Ok(()) +} + +async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> { + let claude_dir = run_dir.join(".claude"); + let settings_path = claude_dir.join("settings.local.json"); + let next = match tokio::fs::read_to_string(&settings_path).await { + Ok(existing) => merge_claude_settings_json(&existing, project_root), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Some(claude_settings_seed_value(project_root)) + } + Err(err) => return Err(err), + }; + let Some(next) = next else { + return Ok(()); + }; + tokio::fs::create_dir_all(&claude_dir).await?; + let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; + body.push('\n'); + write_atomically(&settings_path, &body) +} + +async fn migrate_claude_mcp_config( + run_dir: &Path, + profile: &AgentProfile, + runtime: Option<&McpRuntime>, +) -> Result<(), std::io::Error> { + let Some(target) = claude_mcp_config_target(profile) else { + return Ok(()); + }; + let Some(runtime) = runtime else { + return Ok(()); + }; + let mcp_path = run_dir.join(target); + let desired_server = mcp_server_entry(profile, Some(runtime)); + + let next = match tokio::fs::read_to_string(&mcp_path).await { + Ok(existing) => merge_mcp_json(&existing, desired_server), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Some(wrap_idea_mcp_server(desired_server)) + } + Err(err) => return Err(err), + }; + let Some(next) = next else { + return Ok(()); + }; + if let Some(parent) = mcp_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; + body.push('\n'); + write_atomically(&mcp_path, &body) +} + +fn is_claude_mcp_profile(profile: &AgentProfile) -> bool { + let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude) + || matches!( + &profile.context_injection, + ContextInjection::ConventionFile { target } + if target + .rsplit(['/', '\\']) + .next() + .unwrap_or(target) + .eq_ignore_ascii_case("CLAUDE.md") + ); + is_claude && claude_mcp_config_target(profile).is_some() +} + +fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> { + match profile.mcp.as_ref().map(|mcp| &mcp.config) { + Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()), + _ => None, + } +} + +/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé +/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP +/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage. +/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global). +/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer +/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le +/// projecteur Codex. Best-effort, idempotent. +async fn migrate_codex_run_dir( + project: &Project, + agent_id: &AgentId, + profile: &AgentProfile, +) -> Result<(), std::io::Error> { + let run_dir = project + .root + .as_str() + .trim_end_matches(['/', '\\']) + .to_owned() + + &format!("/.ideai/run/{agent_id}"); + let run_dir_path = Path::new(&run_dir); + if tokio::fs::metadata(run_dir_path).await.is_err() { + return Ok(()); + } + + let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + migrate_codex_mcp_config( + run_dir_path, + project.root.as_str(), + profile, + runtime.as_ref(), + ) + .await +} + +async fn migrate_codex_mcp_config( + run_dir: &Path, + project_root: &str, + profile: &AgentProfile, + runtime: Option<&McpRuntime>, +) -> Result<(), std::io::Error> { + let Some((target, _home_env)) = codex_mcp_config_target(profile) else { + return Ok(()); + }; + // Sans runtime réel, seule une déclaration minimale est disponible : on ne + // régénère pas (mirror de `migrate_claude_mcp_config`). + let Some(runtime) = runtime else { + return Ok(()); + }; + let toml_path = run_dir.join(target); + let declaration = mcp_server_entry_toml(profile, Some(runtime)); + + // Ne jamais clobber le fichier complet ici : il porte aussi la projection de + // permissions Codex. On répare seulement la table MCP et les entrées trust. + let existing = match tokio::fs::read_to_string(&toml_path).await { + Ok(existing) => Some(existing), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => return Err(err), + }; + let desired = codex_config_toml_for_migration( + existing.as_deref(), + &declaration, + run_dir.to_string_lossy().as_ref(), + project_root, + ); + if existing.as_deref() == Some(desired.as_str()) { + return Ok(()); + } + if let Some(parent) = toml_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + write_atomically(&toml_path, &desired) +} + +fn is_codex_mcp_profile(profile: &AgentProfile) -> bool { + let is_codex = profile.structured_adapter == Some(StructuredAdapter::Codex) + || matches!( + &profile.context_injection, + ContextInjection::ConventionFile { target } + if target + .rsplit(['/', '\\']) + .next() + .unwrap_or(target) + .eq_ignore_ascii_case("AGENTS.md") + ); + is_codex && codex_mcp_config_target(profile).is_some() +} + +fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> { + match profile.mcp.as_ref().map(|mcp| &mcp.config) { + Some(McpConfigStrategy::TomlConfigHome { target, home_env }) => { + Some((target.as_str(), home_env.as_str())) + } + _ => None, + } +} + +/// Rend le contenu du `config.toml` Codex (table `[mcp_servers.idea]`) en réutilisant +/// l'encodeur TOML partagé [`domain::McpServerWiring::to_config_toml`] (D2) — même +/// source de wiring que la déclaration `.mcp.json` Claude, donc zéro dérive. +fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> String { + let transport = profile + .mcp + .as_ref() + .map_or(McpTransport::Stdio, |m| m.transport); + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + rt.endpoint.clone(), + "--project".to_owned(), + rt.project_id.clone(), + "--requester".to_owned(), + rt.requester.clone(), + ], + ), + None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), + }; + domain::McpServerWiring::new(command, args, transport).to_config_toml() +} + +fn codex_config_toml_for_migration( + existing: Option<&str>, + mcp_declaration: &str, + run_dir: &str, + project_root: &str, +) -> String { + let mut text = replace_toml_table_block( + existing.unwrap_or_default(), + "mcp_servers.idea", + mcp_declaration.trim_end(), + ); + text = ensure_codex_project_trust(&text, run_dir); + text = ensure_codex_project_trust(&text, project_root); + if !text.ends_with('\n') { + text.push('\n'); + } + text +} + +fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String { + let header = format!("[{table}]"); + let mut out = Vec::new(); + let mut skipping = false; + let mut inserted = false; + + for line in existing.lines() { + let trimmed = line.trim(); + if trimmed == header { + if !inserted { + push_toml_block(&mut out, replacement); + inserted = true; + } + skipping = true; + continue; + } + if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') { + skipping = false; + } + if !skipping { + out.push(line.to_owned()); + } + } + + if !inserted { + if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) { + out.push(String::new()); + } + push_toml_block(&mut out, replacement); + } + + out.join("\n") +} + +fn push_toml_block(out: &mut Vec, block: &str) { + out.extend(block.lines().map(ToOwned::to_owned)); +} + +fn ensure_codex_project_trust(existing: &str, path: &str) -> String { + if path.is_empty() { + return existing.to_owned(); + } + let header = format!(r#"[projects.{}]"#, toml_quoted(path)); + if existing.lines().any(|line| line.trim() == header) { + return existing.to_owned(); + } + let mut text = existing.trim_end().to_owned(); + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&header); + text.push_str("\ntrust_level = \"trusted\"\n"); + text +} + +fn toml_quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option { + let mut doc = match serde_json::from_str::(existing) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)), + }; + let before = doc.clone(); + let root = doc.as_object_mut().expect("object preserved above"); + let permissions = root + .entry("permissions".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !permissions.is_object() { + *permissions = Value::Object(Map::new()); + } + let permissions = permissions + .as_object_mut() + .expect("permissions forced to object"); + permissions.insert( + "defaultMode".to_owned(), + Value::String("bypassPermissions".to_owned()), + ); + permissions.insert( + "additionalDirectories".to_owned(), + Value::Array(merge_string_array( + permissions.get("additionalDirectories"), + [project_root], + )), + ); + permissions.insert( + "allow".to_owned(), + Value::Array(merge_string_array( + permissions.get("allow"), + ["Read", "Edit", "Write", "Bash"], + )), + ); + permissions.insert( + "deny".to_owned(), + Value::Array(merge_string_array( + permissions.get("deny"), + [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(rm -rf ~/*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)", + ], + )), + ); + root.insert( + "skipDangerousModePermissionPrompt".to_owned(), + Value::Bool(true), + ); + root.insert( + "enabledMcpjsonServers".to_owned(), + Value::Array(merge_string_array( + root.get("enabledMcpjsonServers"), + ["idea"], + )), + ); + let sandbox = root + .entry("sandbox".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !sandbox.is_object() { + *sandbox = Value::Object(Map::new()); + } + let sandbox = sandbox.as_object_mut().expect("sandbox forced to object"); + sandbox.insert("enabled".to_owned(), Value::Bool(false)); + + if doc != before { + Some(doc) + } else { + None + } +} + +fn merge_mcp_json(existing: &str, desired_server: Value) -> Option { + let mut doc = match serde_json::from_str::(existing) { + Ok(Value::Object(map)) => map, + Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)), + }; + let mcp_servers = doc + .entry("mcpServers".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !mcp_servers.is_object() { + *mcp_servers = Value::Object(Map::new()); + } + let changed = mcp_servers.get("idea") != Some(&desired_server); + if !changed { + return None; + } + match mcp_servers { + Value::Object(servers) => { + servers.insert("idea".to_owned(), desired_server); + Some(Value::Object(doc)) + } + _ => None, + } +} + +fn wrap_idea_mcp_server(server: Value) -> Value { + let mut servers = Map::new(); + servers.insert("idea".to_owned(), server); + let mut root = Map::new(); + root.insert("mcpServers".to_owned(), Value::Object(servers)); + Value::Object(root) +} + +fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value { + let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) { + Some(McpTransport::Socket) => "socket", + Some(McpTransport::Stdio) | None => "stdio", + }; + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + Value::String("mcp-server".to_owned()), + Value::String("--endpoint".to_owned()), + Value::String(rt.endpoint.clone()), + Value::String("--project".to_owned()), + Value::String(rt.project_id.clone()), + Value::String("--requester".to_owned()), + Value::String(rt.requester.clone()), + ], + ), + None => ( + "idea".to_owned(), + vec![Value::String("mcp-server".to_owned())], + ), + }; + let mut server = Map::new(); + server.insert("command".to_owned(), Value::String(command)); + server.insert("args".to_owned(), Value::Array(args)); + server.insert("transport".to_owned(), Value::String(transport.to_owned())); + Value::Object(server) +} + +fn claude_settings_seed_value(project_root: &str) -> Value { + json!({ + "permissions": { + "defaultMode": "bypassPermissions", + "additionalDirectories": [project_root], + "allow": ["Read", "Edit", "Write", "Bash"], + "deny": [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(rm -rf ~/*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)" + ] + }, + "skipDangerousModePermissionPrompt": true, + "enabledMcpjsonServers": ["idea"], + "sandbox": { "enabled": false } + }) +} + +fn merge_string_array<'a>( + existing: Option<&Value>, + required: impl IntoIterator, +) -> Vec { + let mut seen = HashSet::::new(); + let mut out = Vec::new(); + + if let Some(existing) = existing.and_then(Value::as_array) { + for item in existing.iter().filter_map(Value::as_str) { + if seen.insert(item.to_owned()) { + out.push(Value::String(item.to_owned())); + } + } + } + + for item in required { + if seen.insert(item.to_owned()) { + out.push(Value::String(item.to_owned())); + } + } + + out +} + +fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let file_name = path + .file_name() + .map(OsString::from) + .unwrap_or_else(|| OsString::from("tmp")); + let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4()); + let tmp_path = path.with_file_name(tmp_name); + + std::fs::write(&tmp_path, content.as_bytes())?; + #[cfg(windows)] + if path.exists() { + let _ = std::fs::remove_file(path); + } + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +#[cfg(test)] +mod run_dir_migration_tests { + use super::{ + claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry, + merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir, + }; + use application::McpRuntimeProvider; + use domain::ids::{AgentId, ProfileId, ProjectId}; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, + }; + use serde_json::json; + use uuid::Uuid; + + fn claude_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + )) + } + + fn codex_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(10)), + "OpenAI Codex CLI", + "codex", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(), + McpTransport::Stdio, + )) + } + + fn runtime(agent_id: AgentId) -> application::McpRuntime { + application::McpRuntime { + exe: "/opt/IdeA.AppImage".to_owned(), + endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), + project_id: ProjectId::from_uuid(Uuid::from_u128(1234)) + .as_uuid() + .simple() + .to_string(), + requester: agent_id.to_string(), + } + } + + #[test] + fn merge_claude_settings_adds_idea_and_preserves_existing_entries() { + let merged = merge_claude_settings_json( + r#"{ + "permissions": { + "additionalDirectories": ["/tmp/custom"], + "allow": ["Read"], + "deny": ["Bash(custom)"] + }, + "enabledMcpjsonServers": ["other"], + "extra": true +}"#, + "/home/me/proj", + ) + .unwrap(); + + let parsed = merged; + assert_eq!(parsed["extra"], json!(true)); + assert_eq!( + parsed["permissions"]["defaultMode"], + json!("bypassPermissions") + ); + assert!(parsed["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!("/tmp/custom"))); + assert!(parsed["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!("/home/me/proj"))); + assert!(parsed["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("other"))); + assert!(parsed["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("idea"))); + } + + #[test] + fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() { + let agent_id = AgentId::from_uuid(Uuid::from_u128(77)); + let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id))); + let merged = merge_mcp_json( + r#"{ + "mcpServers": { + "idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" }, + "other": { "command": "keep-me", "args": [] } + } +}"#, + desired.clone(), + ) + .unwrap(); + + let parsed = merged; + assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me")); + assert_eq!(parsed["mcpServers"]["idea"], desired); + } + + #[test] + fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() { + let agent_id = AgentId::from_uuid(Uuid::from_u128(88)); + let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4())); + let project_root = temp.join("project"); + let run_dir = project_root.join(".ideai/run").join(agent_id.to_string()); + std::fs::create_dir_all(run_dir.join(".claude")).unwrap(); + std::fs::write( + run_dir.join(".claude/settings.local.json"), + r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#, + ) + .unwrap(); + std::fs::write( + run_dir.join(".mcp.json"), + r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#, + ) + .unwrap(); + + let project = domain::Project::new( + ProjectId::from_uuid(Uuid::from_u128(1234)), + "demo", + domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), + domain::remote::RemoteRef::local(), + 1, + ) + .unwrap(); + let profile = claude_profile(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + migrate_claude_run_dir(&project, &agent_id, &profile) + .await + .unwrap(); + }); + + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(), + ) + .unwrap(); + assert!(settings["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("idea"))); + assert!(settings["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!(project.root.as_str()))); + + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap()) + .unwrap(); + let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider + .runtime_for(&project, agent_id) + .unwrap(); + assert_eq!( + mcp["mcpServers"]["idea"], + mcp_server_entry(&claude_profile(), Some(&expected_runtime)) + ); + + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() { + let agent_id = AgentId::from_uuid(Uuid::from_u128(89)); + let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4())); + let project_root = temp.join("project"); + let run_dir = project_root.join(".ideai/run").join(agent_id.to_string()); + std::fs::create_dir_all(run_dir.join(".codex")).unwrap(); + std::fs::write( + run_dir.join(".codex/config.toml"), + r#"approval_policy = "never" +sandbox_mode = "workspace-write" + +[mcp_servers.idea] +command = "stale" +args = ["mcp-server"] +transport = "stdio" +"#, + ) + .unwrap(); + + let project = domain::Project::new( + ProjectId::from_uuid(Uuid::from_u128(1234)), + "demo", + domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), + domain::remote::RemoteRef::local(), + 1, + ) + .unwrap(); + let profile = codex_profile(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + migrate_codex_run_dir(&project, &agent_id, &profile) + .await + .unwrap(); + }); + + let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap(); + assert!(config.contains(r#"approval_policy = "never""#)); + assert!(config.contains(r#"sandbox_mode = "workspace-write""#)); + assert!(config.contains("[mcp_servers.idea]")); + assert!(config.contains(r#"default_tools_approval_mode = "approve""#)); + assert!(config.contains("tool_timeout_sec = 86400")); + assert!(!config.contains(r#"command = "stale""#)); + assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy()))); + assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str()))); + + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn claude_profile_guard_matches_only_claude_mcp_profiles() { + assert!(is_claude_mcp_profile(&claude_profile())); + } + + #[test] + fn invalid_settings_fall_back_to_seed() { + let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap(); + assert_eq!(merged, claude_settings_seed_value("/home/me/proj")); + } + + #[test] + fn malformed_typed_settings_are_repaired_without_panicking() { + let merged = merge_claude_settings_json( + r#"{ + "permissions": [], + "sandbox": false, + "enabledMcpjsonServers": "idea" +}"#, + "/home/me/proj", + ) + .unwrap(); + + assert_eq!( + merged["permissions"]["defaultMode"], + json!("bypassPermissions") + ); + assert_eq!(merged["sandbox"]["enabled"], json!(false)); + assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"])); + } +} + +/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the +/// bound [`LocalSocketListener`] or `None` if the bind fails (e.g. a stale socket +/// file from a crashed run). A `None` never figes open/close — the registry entry +/// is still created so the lifecycle stays idempotent; the real accept/serve (M5c) +/// will surface a hard failure if it actually needs the listener. +/// +/// On Unix this binds a filesystem-path UDS under the per-user runtime dir; the +/// parent directory is created if missing, and a corpse socket from a previous run +/// is replaced (`reclaim_name` so the file is unlinked on drop). On Windows it binds +/// the named pipe — no filesystem entry to manage. +#[must_use] +fn bind_endpoint(endpoint: &McpEndpoint) -> Option { + // Ensure the runtime dir exists (Unix path sockets need their parent dir). + if let Some(path) = endpoint.socket_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding. + // `reclaim_name(true)` (below) only unlinks the socket on *drop*; in + // `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time, + // so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test + // proves this). We therefore unlink it ourselves first — but only when the + // path is actually a **socket** (never clobber a real file we don't own). + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if let Ok(meta) = std::fs::symlink_metadata(&path) { + if meta.file_type().is_socket() { + let _ = std::fs::remove_file(&path); + } + } + } + } + let name = endpoint.as_cli_arg().to_fs_name::().ok()?; + ListenerOptions::new() + .name(name) + // Reclaim (unlink) on drop so a clean close leaves no socket behind. + .reclaim_name(true) + .create_tokio() + .ok() +} + +/// Drives one accepted loopback peer (= one `idea mcp-server` bridge = one agent): +/// reads its **handshake line**, then serves the rest of the stream as JSON-RPC. +/// +/// Sequence (cadrage v5 §1.4, M5b handshake format): +/// 1. Split the duplex connection so reads and writes are independent. +/// 2. Read **one** newline-terminated handshake line +/// (`{"project":"…","requester":"…"}`) off a `BufReader` over the read half. +/// 3. If the handshake's `project` is present and **mismatches** this server's +/// project, close the connection (return) without serving — a defensive guard, it +/// logs nothing and never crashes the accept loop. +/// 4. Wrap the **same** `BufReader` (carrying any bytes already buffered past the +/// handshake) + the write half in a [`StdioTransport`] and hand it to +/// [`McpServer::serve_as`] tagged with the handshake's `requester` — so +/// `OrchestratorRequestProcessed.requester_id` becomes the real agent id. +/// +/// Generic over the stream type so it never names the `interprocess` connection +/// type and stays unit-testable over an in-memory duplex. +async fn serve_peer(server: Arc, expected_project: &str, conn: S) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let (read_half, write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + + // 1 handshake line. A read error or immediate EOF ⇒ no peer to serve. + let mut handshake = String::new(); + match reader.read_line(&mut handshake).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + + let (handshake_project, requester) = parse_handshake(&handshake); + + // Defensive: a bridge dialed the wrong project's endpoint. Drop it cleanly. + if !handshake_project.is_empty() + && !expected_project.is_empty() + && handshake_project != expected_project + { + return; + } + + // The same BufReader keeps any bytes already buffered past the handshake, so no + // JSON-RPC line is lost between the handshake and the serve loop. + let mut transport = StdioTransport::from_buffered(reader, write_half); + server.serve_as(requester, &mut transport).await; +} + +/// Parses the M5b handshake line into `(project, requester)`. Tolerant: a malformed +/// or partial line yields empty strings (⇒ legacy `"mcp"` requester, no project +/// guard), never an error — the serve loop must never crash on a bad handshake. +fn parse_handshake(line: &str) -> (String, String) { + serde_json::from_str::(line.trim()) + .ok() + .map(|v| { + let project = v + .get("project") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + let requester = v + .get("requester") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + (project, requester) + }) + .unwrap_or_default() +} + +/// Lifecycle handle for a per-project [`McpServer`] supervision task — the **twin** +/// of [`OrchestratorWatchHandle`](infrastructure::OrchestratorWatchHandle). +/// +/// It carries the **same** stop mechanism as the watcher (a one-shot +/// `mpsc::Sender<()>`): [`stop`](Self::stop) signals the task to exit, and dropping +/// the handle stops it too (the channel closes). +/// +/// ## Accept-and-serve-per-peer (M5c) +/// +/// The supervision task runs an **`accept` loop** on the bound loopback `listener`, +/// arbitrated against the stop signal by `tokio::select!`: +/// - **`accept`** is async and parks on the absence of a peer, so the loop never +/// figes the open/close of a project — at project-open time no CLI is connected +/// yet (a peer only appears once an MCP-capable agent is launched, M5d). +/// - **each accepted connection** = one `idea mcp-server` bridge = one agent. The +/// task reads the **handshake line** (`{"project","requester"}`, cadrage v5 §1.4), +/// then spawns an isolated task running [`McpServer::serve_as`] over the rest of +/// the stream, so one peer's disconnection/error never affects the others or the +/// accept loop. +/// - **stop** breaks the loop, **aborts** the in-flight serve tasks (`JoinSet`), +/// and drops the listener — which on Unix unlinks the socket file via +/// interprocess' reclaim guard, so closing a project leaves no socket behind. +pub struct McpServerHandle { + stop: tokio::sync::mpsc::Sender<()>, + /// The loopback address this server listens on — the single source of truth + /// ([`mcp_endpoint`]) shared with the CLI-declaration writer (M5d). + endpoint: McpEndpoint, +} + +impl McpServerHandle { + /// Spawns the supervision task that owns `server` and the bound `listener`, runs + /// the accept-and-serve-per-peer loop, and stops cleanly on signal. Must run + /// inside the ambient Tokio runtime (async driving adapters satisfy this), exactly + /// like [`FsOrchestratorWatcher::start`](infrastructure::FsOrchestratorWatcher). + #[must_use] + fn start( + server: McpServer, + endpoint: McpEndpoint, + listener: Option, + project_id: String, + ) -> Self { + let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel::<()>(1); + // The base server is shared (Arc) so each accepted peer derives its own + // requester-tagged clone (McpServer::serve_as) without contending. + let server = Arc::new(server); + tokio::spawn(async move { + // No listener (bind failed, e.g. stale socket): nothing to accept. Park + // on stop so the lifecycle stays idempotent and non-blocking. + let Some(listener) = listener else { + let _ = stop_rx.recv().await; + return; + }; + // In-flight per-peer serve tasks; aborted en masse on stop so no serve + // outlives the project's close. + let mut serves: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + loop { + tokio::select! { + // Stop requested (or the handle dropped, closing the channel). + _ = stop_rx.recv() => break, + accepted = listener.accept() => { + match accepted { + Ok(conn) => { + let server = Arc::clone(&server); + let expected_project = project_id.clone(); + serves.spawn(async move { + serve_peer(server, &expected_project, conn).await; + }); + } + // A transient accept error must not kill the loop; the + // listener stays bound and keeps accepting the next peer. + Err(_) => continue, + } + } + } + } + // Stop: terminate every in-flight serve, then drop the listener (unlinks + // the Unix socket file). Pending peers die with their owning CLI anyway. + serves.abort_all(); + drop(serves); + drop(listener); + }); + Self { + stop: stop_tx, + endpoint, + } + } + + /// The loopback endpoint this project's server is bound to (M5a source of truth). + #[must_use] + pub fn endpoint(&self) -> &McpEndpoint { + &self.endpoint + } + + /// Signals the supervision task to stop (best-effort; dropping the handle also + /// stops it). Mirrors [`OrchestratorWatchHandle::stop`](infrastructure::OrchestratorWatchHandle::stop). + pub fn stop(&self) { + let _ = self.stop.try_send(()); + } +} + +/// Build the project memory recall port from an embedder profile. +/// +/// This is the only place that knows the concrete recall adapters (DIP): callers +/// only ever see `Arc`. With the default `none` profile, +/// `embedder_from_profile` returns `None` and we hand back the plain +/// `NaiveMemoryRecall` — strictly identical, dependency-free behaviour. When an +/// embedder is configured, we wrap naïve + vector recall in an +/// `AdaptiveMemoryRecall` that switches stages live per the profile strategy. +pub(crate) fn build_memory_recall( + fs: Arc, + store: Arc, + profile: &EmbedderProfile, + onnx_cache_dir: &std::path::Path, +) -> Arc { + let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc; + match embedder_from_profile(profile, onnx_cache_dir) { + None => naive, + Some(embedder) => { + let embedder: Arc = Arc::from(embedder); + let vector: Arc = Arc::new(VectorMemoryRecall::new( + embedder, + Arc::clone(&store), + Arc::clone(&fs), + )); + Arc::new(AdaptiveMemoryRecall::new( + naive, + vector, + Arc::clone(&store), + profile.strategy, + )) + } + } +} + +#[cfg(test)] +mod background_tasks_b7_tests { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use domain::ports::{BackgroundTaskStore, ProjectStore, StoreError}; + use domain::{ + AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, + BackgroundTaskWakePolicy, Project, ProjectId, ProjectPath, RemoteRef, TaskId, + }; + use tokio::sync::mpsc::unbounded_channel; + use uuid::Uuid; + + use super::{AppBackgroundTaskStore, AppReconcileBackgroundTasks}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-b7-reconcile-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn project_path(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[derive(Default)] + struct FakeProjects { + projects: Mutex>, + } + + impl FakeProjects { + fn insert(&self, project: Project) { + self.projects.lock().unwrap().insert(project.id, project); + } + } + + #[async_trait] + impl ProjectStore for FakeProjects { + async fn list_projects(&self) -> Result, StoreError> { + Ok(self.projects.lock().unwrap().values().cloned().collect()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + self.projects + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save_project(&self, project: &Project) -> Result<(), StoreError> { + self.insert(project.clone()); + Ok(()) + } + + async fn save_workspace(&self, _workspace: &domain::Workspace) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_workspace(&self) -> Result { + Ok(domain::Workspace::default()) + } + } + + struct FixedClock(i64); + + impl domain::ports::Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } + } + + fn project_id(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) + } + + fn agent_id(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) + } + + fn task_id(n: u128) -> TaskId { + TaskId::from_uuid(Uuid::from_u128(n)) + } + + fn project(id: ProjectId, root: ProjectPath) -> Project { + Project::new(id, "demo", root, RemoteRef::local(), 1_000).unwrap() + } + + fn completed_task( + id: u128, + project_id: ProjectId, + owner: AgentId, + wake_policy: BackgroundTaskWakePolicy, + ) -> BackgroundTask { + BackgroundTask::new( + task_id(id), + project_id, + owner, + BackgroundTaskKind::Command { + label: format!("task-{id}"), + }, + wake_policy, + 1_000, + None, + ) + .unwrap() + .transition(BackgroundTaskState::Running, 1_010) + .unwrap() + .complete(BackgroundTaskResult::Success { + finished_at_ms: 1_020, + exit_code: Some(0), + summary: "ok".to_owned(), + stdout_tail: None, + stderr_tail: None, + }) + .unwrap() + } + + fn running_task(id: u128, project_id: ProjectId, owner: AgentId) -> BackgroundTask { + BackgroundTask::new( + task_id(id), + project_id, + owner, + BackgroundTaskKind::Command { + label: format!("task-{id}"), + }, + BackgroundTaskWakePolicy::WakeOwner, + 1_000, + None, + ) + .unwrap() + .transition(BackgroundTaskState::Running, 1_010) + .unwrap() + } + + #[tokio::test] + async fn app_reconcile_requeues_wake_owner_marks_record_only_and_repairs_orphan_running() { + let tmp = TempDir::new(); + let project_id = project_id(10); + let owner = agent_id(100); + let projects = Arc::new(FakeProjects::default()); + projects.insert(project(project_id, tmp.project_path())); + + let store = Arc::new(AppBackgroundTaskStore::new( + Arc::clone(&projects) as Arc + )); + let wake_owner = completed_task(1, project_id, owner, BackgroundTaskWakePolicy::WakeOwner); + let record_only = + completed_task(2, project_id, owner, BackgroundTaskWakePolicy::RecordOnly); + let orphan = running_task(3, project_id, owner); + store.create(&wake_owner).await.unwrap(); + store.create(&record_only).await.unwrap(); + store.create(&orphan).await.unwrap(); + + let (ready_tx, mut ready_rx) = unbounded_channel(); + let reconcile = AppReconcileBackgroundTasks { + projects: Arc::clone(&projects) as Arc, + store: Arc::clone(&store), + clock: Arc::new(FixedClock(2_000)), + ready: ready_tx, + }; + + reconcile.execute(project_id).await.unwrap(); + + let mut ready = Vec::new(); + while let Ok(item) = ready_rx.try_recv() { + ready.push(item.task_id); + } + assert_eq!(ready, vec![wake_owner.id, orphan.id]); + + let wake_owner_after = store.get(wake_owner.id).await.unwrap().unwrap(); + assert!(!wake_owner_after.completion_delivered); + + let record_only_after = store.get(record_only.id).await.unwrap().unwrap(); + assert!(record_only_after.completion_delivered); + + let orphan_after = store.get(orphan.id).await.unwrap().unwrap(); + assert_eq!(orphan_after.state, BackgroundTaskState::Failed); + assert!(orphan_after.has_pending_completion_delivery()); + assert!(matches!( + orphan_after.result, + Some(BackgroundTaskResult::Failure { + finished_at_ms: 2_000, + .. + }) + )); + } +} + +#[cfg(test)] +mod mcp_serve_peer_tests { + //! M5c — server side of the bind transport: [`serve_peer`] + [`parse_handshake`]. + //! + //! These drive the **private** `serve_peer` free function over an in-memory + //! `tokio::io::duplex` (no socket, no child process), the test seam the prod + //! code was made generic for (`serve_peer` over any `AsyncRead+AsyncWrite`). + //! The `OrchestratorService` is wired over the **same** in-memory fakes the + //! infrastructure MCP tests use (`infrastructure/tests/mcp_server.rs`), so MCP + //! behaviour is asserted against a real service with zero I/O. + //! + //! GARDE-FOU : every `await` that could block on a peer that never speaks is + //! bounded by `tokio::time::timeout`; a hung accept/serve fails fast instead of + //! hanging the suite. + + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use async_trait::async_trait; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use application::{ + CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, + OrchestratorService, TerminalSessions, UpdateAgentContext, + }; + use domain::agent::{AgentManifest, ManifestEntry}; + use domain::events::{DomainEvent, OrchestrationSource}; + use domain::ids::{AgentId, ProfileId, ProjectId, SkillId}; + use domain::markdown::MarkdownDoc; + use domain::ports::{ + AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, + ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, + PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, + StoreError, + }; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, + }; + use domain::project::{Project, ProjectPath}; + use domain::remote::RemoteRef; + use domain::skill::{Skill, SkillScope}; + use domain::{PtySize, SessionId}; + use serde_json::{json, Value}; + use uuid::Uuid; + + use super::serve_peer; + use infrastructure::McpServer; + + /// Test timeout for any single peer interaction. Generous but finite: a correct + /// duplex round-trip is sub-millisecond, so this only ever fires on a real hang. + const TIMEOUT: Duration = Duration::from_secs(5); + + // ----------------------------------------------------------------------- + // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established + // MCP harness), trimmed to exactly what `OrchestratorService::new` needs. + // ----------------------------------------------------------------------- + + #[derive(Default)] + struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, + } + #[derive(Clone)] + struct FakeContexts(Arc>); + impl FakeContexts { + fn new() -> Self { + Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + orchestrator: None, + }, + contents: HashMap::new(), + }))) + } + fn seed_agent(&self, name: &str) -> AgentId { + let id = AgentId::from_uuid(Uuid::new_v4()); + let mut inner = self.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry { + agent_id: id, + name: name.to_owned(), + md_path: format!("agents/{name}.md"), + profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), + template_id: None, + synchronized: false, + synced_template_version: None, + skills: Vec::new(), + }); + id + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } + } + #[async_trait] + impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + Ok(MarkdownDoc::new( + self.0 + .lock() + .unwrap() + .contents + .get(&md) + .cloned() + .unwrap_or_default(), + )) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.lock().unwrap().manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + self.0.lock().unwrap().manifest = manifest.clone(); + Ok(()) + } + } + + #[derive(Clone)] + struct FakeProfiles(Arc>); + #[async_trait] + impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } + } + + #[derive(Default)] + struct FakeSkills; + #[async_trait] + impl SkillStore for FakeSkills { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { + Ok(Vec::new()) + } + async fn get( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } + } + + #[derive(Default)] + struct FakeRecall; + #[async_trait] + impl domain::ports::MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &domain::ports::MemoryQuery, + ) -> Result, domain::ports::MemoryError> { + Ok(Vec::new()) + } + } + + struct FakeRuntime; + #[async_trait] + impl AgentRuntime for FakeRuntime { + async fn detect(&self, _p: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, + }) + } + } + + #[derive(Clone, Default)] + struct FakeFs; + #[async_trait] + impl FileSystem for FakeFs { + async fn read(&self, p: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(p.as_str().to_owned())) + } + async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { + Ok(()) + } + async fn exists(&self, _p: &RemotePath) -> Result { + Ok(false) + } + async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _p: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + } + + #[derive(Clone)] + struct FakePty; + #[async_trait] + impl PtyPort for FakePty { + async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { + Ok(PtyHandle { + session_id: SessionId::from_uuid(Uuid::from_u128(777)), + }) + } + fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _h: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn wait(&self, _h: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } + fn try_wait(&self, _h: &PtyHandle) -> Result, PtyError> { + Ok(Some(ExitStatus { code: Some(0) })) + } + async fn kill(&self, _h: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } + } + + #[derive(Default, Clone)] + struct NoopBus; + impl EventBus for NoopBus { + fn publish(&self, _e: DomainEvent) {} + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } + } + + struct SeqIds(Mutex); + impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } + } + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1000)), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() + } + + /// The hyphen-free hex project-id the handshake guard compares against — the + /// exact form `ensure_mcp_server` derives and M5d's `--project` reuses. + fn project_id_arg(p: &Project) -> String { + p.id.as_uuid().simple().to_string() + } + + /// A capturing event sink (the MCP twin of the file watcher's publish closure): + /// records every [`DomainEvent`] so a test can assert `requester_id`. + fn capturing_events() -> ( + Arc, + Arc>>, + ) { + let captured = Arc::new(Mutex::new(Vec::new())); + let sink = captured.clone(); + let publish: Arc = + Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e)); + (publish, captured) + } + + /// Builds an `OrchestratorService` over the in-memory fakes (no structured + /// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`. + fn build_service(contexts: FakeContexts) -> Arc { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour + // `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. + let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::stdin(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); + let sessions = Arc::new(TerminalSessions::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + Arc::new(OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + )) + } + + // --- duplex client helpers --------------------------------------------- + + /// Frames a `tools/call` request line (newline-terminated, as the transport + /// expects per `StdioTransport::recv`). + fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String { + let mut s = serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": tool, "arguments": arguments } + })) + .unwrap(); + s.push('\n'); + s + } + + /// A `tools/list` request line. + fn tools_list_line(id: i64) -> String { + let mut s = serde_json::to_string(&json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/list" + })) + .unwrap(); + s.push('\n'); + s + } + + /// A handshake line `{"project":..,"requester":..}` followed by `\n`. + fn handshake_line(project: &str, requester: &str) -> String { + format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n") + } + + /// Reads exactly one newline-delimited JSON-RPC response off the client side of + /// the duplex, bounded by [`TIMEOUT`]. Returns `None` on EOF/timeout (the peer + /// closed without replying — used by the project-guard test). + async fn read_one_response(client: &mut R) -> Option + where + R: tokio::io::AsyncRead + Unpin, + { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + match tokio::time::timeout(TIMEOUT, client.read(&mut byte)).await { + Ok(Ok(0)) => return None, // EOF before a full line + Ok(Ok(_)) => { + if byte[0] == b'\n' { + break; + } + buf.push(byte[0]); + } + Ok(Err(_)) => return None, + Err(_) => return None, // GARDE-FOU: timed out waiting for a reply + } + } + serde_json::from_slice(&buf).ok() + } + + /// Spawns `serve_peer` over the server half of a fresh duplex and returns the + /// client half plus the join handle. The peer is bounded by the test's reads. + fn spawn_peer( + server: Arc, + expected_project: String, + ) -> (tokio::io::DuplexStream, tokio::task::JoinHandle<()>) { + let (client, server_side) = tokio::io::duplex(64 * 1024); + let handle = tokio::spawn(async move { + serve_peer(server, &expected_project, server_side).await; + }); + (client, handle) + } + + // ----------------------------------------------------------------------- + // 1. Handshake + tools/list end-to-end over the duplex. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn handshake_then_tools_list_round_trips_over_duplex() { + let proj = project(); + let service = build_service(FakeContexts::new()); + let server = Arc::new(McpServer::new(service, proj.clone())); + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + // Write the handshake line, then a tools/list request. + client + .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) + .await + .unwrap(); + client + .write_all(tools_list_line(1).as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client)) + .await + .expect("GARDE-FOU: tools/list timed out") + .expect("a tools/list response line"); + + assert_eq!(resp["id"], json!(1)); + let tools = resp["result"]["tools"].as_array().expect("tools array"); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for expected in [ + "idea_list_agents", + "idea_ask_agent", + "idea_run_in_background", + "idea_launch_agent", + "idea_stop_agent", + "idea_update_context", + "idea_create_skill", + // FileGuard-mediated context/memory tools (cadrage C7). + "idea_context_read", + "idea_context_propose", + "idea_memory_read", + "idea_memory_write", + // Skill-awareness : lecture à la demande du corps d'un skill. + "idea_skill_read", + // Conversation inter-agent headless : réponse inline capturée depuis le Final. + "idea_ask_agent", + // Live-state (programme live-state, lot LS4). + "idea_workstate_read", + "idea_workstate_set", + // Public ticket tools (Issue domain). + "idea_ticket_create", + "idea_ticket_read", + "idea_ticket_list", + "idea_ticket_update", + "idea_ticket_update_status", + "idea_ticket_update_priority", + "idea_ticket_read_carnet", + "idea_ticket_update_carnet", + "idea_ticket_link", + "idea_ticket_unlink", + "idea_sprint_list", + ] { + assert!( + names.contains(&expected), + "missing tool {expected}; got {names:?}" + ); + } + assert!(!names.contains(&"idea_reply")); + assert_eq!( + tools.len(), + 25, + "exactly the twenty-five exposed idea_* tools; got {names:?}" + ); + + drop(client); // EOF ⇒ serve loop ends + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer task did not finish") + .unwrap(); + } + + // ----------------------------------------------------------------------- + // 2. The handshake's requester is propagated to the processed event. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn handshake_requester_propagates_to_processed_event() { + let proj = project(); + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let service = build_service(contexts); + let (publish, captured) = capturing_events(); + let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + client + .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes()) + .await + .unwrap(); + client + .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + let resp = read_one_response(&mut client) + .await + .expect("a tools/call response"); + assert_eq!(resp["result"]["isError"], json!(false), "got {resp}"); + + drop(client); + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer did not finish") + .unwrap(); + + let events = captured.lock().unwrap(); + let processed: Vec<&DomainEvent> = events + .iter() + .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) + .collect(); + assert_eq!( + processed.len(), + 1, + "exactly one processed event; got {events:?}" + ); + match processed[0] { + DomainEvent::OrchestratorRequestProcessed { + requester_id, + action, + source, + .. + } => { + assert_eq!( + requester_id, "agent-42", + "the real handshake requester must be propagated (not 'mcp')" + ); + assert_eq!(action, "idea_list_agents"); + assert_eq!(*source, OrchestrationSource::Mcp); + } + other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), + } + } + + // ----------------------------------------------------------------------- + // 3. Empty requester in the handshake ⇒ legacy "mcp" label (back-compat). + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn empty_requester_handshake_falls_back_to_legacy_mcp_label() { + let proj = project(); + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let service = build_service(contexts); + let (publish, captured) = capturing_events(); + let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); + // Empty requester ("") in the handshake. + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + client + .write_all(handshake_line(&project_id_arg(&proj), "").as_bytes()) + .await + .unwrap(); + client + .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + let _ = read_one_response(&mut client).await.expect("a response"); + drop(client); + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer did not finish") + .unwrap(); + + let events = captured.lock().unwrap(); + let processed = events + .iter() + .find_map(|e| match e { + DomainEvent::OrchestratorRequestProcessed { requester_id, .. } => { + Some(requester_id.clone()) + } + _ => None, + }) + .expect("a processed event"); + assert_eq!( + processed, "mcp", + "empty handshake requester must keep the legacy 'mcp' label" + ); + } + + // ----------------------------------------------------------------------- + // 4. JSON-RPC bytes glued onto the handshake's write are not lost + // (proves `StdioTransport::from_buffered` preserves buffered bytes). + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn jsonrpc_request_glued_to_handshake_is_served() { + let proj = project(); + let service = build_service(FakeContexts::new()); + let server = Arc::new(McpServer::new(service, proj.clone())); + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + // ONE write carrying the handshake line AND the tools/list line, back to back. + let mut glued = handshake_line(&project_id_arg(&proj), "agent-1"); + glued.push_str(&tools_list_line(7)); + client.write_all(glued.as_bytes()).await.unwrap(); + client.flush().await.unwrap(); + + let resp = read_one_response(&mut client) + .await + .expect("the glued tools/list must still be served"); + assert_eq!( + resp["id"], + json!(7), + "the buffered request id must come through" + ); + assert!( + resp["result"]["tools"].is_array(), + "buffered request produced a real tools/list result; got {resp}" + ); + + drop(client); + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer did not finish") + .unwrap(); + } + + // ----------------------------------------------------------------------- + // 5a. Project guard: a mismatching handshake project ⇒ closed without serving. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn mismatched_project_handshake_is_closed_without_serving() { + let proj = project(); + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let service = build_service(contexts); + let (publish, captured) = capturing_events(); + let server = Arc::new(McpServer::new(service, proj.clone()).with_events(publish)); + // serve_peer is told to expect this project's id... + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + // ...but the handshake claims a DIFFERENT project. + client + .write_all(handshake_line("ffffffffffffffffffffffffffffffff", "intruder").as_bytes()) + .await + .unwrap(); + client + .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + // No reply must come: the peer returned before serving. read returns EOF. + let resp = read_one_response(&mut client).await; + assert!( + resp.is_none(), + "mismatched project must be closed WITHOUT a response; got {resp:?}" + ); + + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer did not finish") + .unwrap(); + + // And nothing was dispatched ⇒ no processed event. + let events = captured.lock().unwrap(); + assert!( + !events + .iter() + .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })), + "a rejected peer must not dispatch anything; got {events:?}" + ); + } + + // ----------------------------------------------------------------------- + // 5b. Empty handshake project ⇒ served normally (guard does not reject). + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn empty_handshake_project_is_served_normally() { + let proj = project(); + let service = build_service(FakeContexts::new()); + let server = Arc::new(McpServer::new(service, proj.clone())); + let (mut client, peer) = spawn_peer(server, project_id_arg(&proj)); + + // Empty project in the handshake — the guard must NOT reject it. + client + .write_all(handshake_line("", "agent-1").as_bytes()) + .await + .unwrap(); + client + .write_all(tools_list_line(1).as_bytes()) + .await + .unwrap(); + client.flush().await.unwrap(); + + let resp = read_one_response(&mut client) + .await + .expect("empty-project handshake must still be served"); + assert!(resp["result"]["tools"].is_array(), "got {resp}"); + + drop(client); + tokio::time::timeout(TIMEOUT, peer) + .await + .expect("GARDE-FOU: peer did not finish") + .unwrap(); + } + + // ----------------------------------------------------------------------- + // 6. Peer isolation: one peer closing/erroring does not stop another. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn one_peer_failure_does_not_affect_a_concurrent_peer() { + let proj = project(); + let service = build_service(FakeContexts::new()); + let server = Arc::new(McpServer::new(service, proj.clone())); + + // Peer A: a broken peer that immediately closes after a partial handshake + // (no newline) — its serve_peer must end without crashing the runtime. + let (mut client_a, peer_a) = spawn_peer(Arc::clone(&server), project_id_arg(&proj)); + client_a.write_all(b"{\"project\":").await.unwrap(); // partial, no newline + client_a.flush().await.unwrap(); + drop(client_a); // abrupt close mid-handshake + + // Peer B: a healthy peer, served concurrently, must still get its reply. + let (mut client_b, peer_b) = spawn_peer(Arc::clone(&server), project_id_arg(&proj)); + client_b + .write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes()) + .await + .unwrap(); + client_b + .write_all(tools_list_line(2).as_bytes()) + .await + .unwrap(); + client_b.flush().await.unwrap(); + + let resp = read_one_response(&mut client_b) + .await + .expect("healthy peer B must be served despite peer A failing"); + assert_eq!(resp["id"], json!(2)); + assert!(resp["result"]["tools"].is_array(), "got {resp}"); + + drop(client_b); + // Both peer tasks must terminate cleanly within the bound. + tokio::time::timeout(TIMEOUT, peer_a) + .await + .expect("GARDE-FOU: peer A did not finish") + .unwrap(); + tokio::time::timeout(TIMEOUT, peer_b) + .await + .expect("GARDE-FOU: peer B did not finish") + .unwrap(); + } + + // ----------------------------------------------------------------------- + // Bonus — parse_handshake unit behaviour (tolerant parsing contract). + // ----------------------------------------------------------------------- + + #[test] + fn parse_handshake_is_tolerant() { + use super::parse_handshake; + // Well-formed. + assert_eq!( + parse_handshake("{\"project\":\"p1\",\"requester\":\"a1\"}\n"), + ("p1".to_owned(), "a1".to_owned()) + ); + // Malformed JSON ⇒ empty strings, never a panic. + assert_eq!(parse_handshake("{not json"), (String::new(), String::new())); + // Missing fields ⇒ empty strings. + assert_eq!(parse_handshake("{}"), (String::new(), String::new())); + } + + // ----------------------------------------------------------------------- + // C7 — câblage du FileGuard context/memory au composition root. + // + // Régression visée (fix `fix/wire-context-guard`) : si quelqu'un oublie à + // nouveau `.with_context_guard(...)`, `require_context_guard()` retombe sur + // `None` et toute commande `context.*`/`memory.*` échoue avec + // `AppError::Invalid("FileGuard context/memory tools are not configured")`. + // Ces tests prouvent que, branché comme dans `App::build`, le service + // traite ces commandes sans cette erreur — et le réfute sans le câblage. + // ----------------------------------------------------------------------- + + use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory}; + use domain::conversation::ConversationParty; + use domain::memory::{ + Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, + }; + use domain::ports::{Clock, MemoryError, MemoryStore}; + use domain::OrchestratorCommand; + use infrastructure::RwFileGuard; + + /// In-memory [`MemoryStore`] (slug → body), the lightest fake that lets the + /// `memory.write` → `memory.read` round-trip exercise the real use cases + /// behind the shared guard. Mirrors the one in `context_guard.rs`'s tests. + #[derive(Default)] + struct FakeMemory { + notes: Mutex>, + } + #[async_trait] + impl MemoryStore for FakeMemory { + async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { + let body = self + .notes + .lock() + .unwrap() + .get(slug.as_str()) + .cloned() + .ok_or(MemoryError::NotFound)?; + Memory::new( + MemoryFrontmatter { + name: slug.clone(), + description: "d".to_owned(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new(body), + ) + .map_err(|e| MemoryError::Frontmatter(e.to_string())) + } + async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { + self.notes + .lock() + .unwrap() + .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); + Ok(()) + } + async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { + Ok(()) + } + async fn read_index( + &self, + _root: &ProjectPath, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + } + + /// Fixed millis clock — `ProposeContext` needs a [`Clock`], unused on the + /// paths these tests drive. + struct FixedClock; + impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 1_700_000_000_000 + } + } + + /// Builds the **same wiring as `App::build`** (state.rs:1070-1129): one + /// shared `RwFileGuard` cast once, cloned into the four C7 use cases, handed + /// to the service via `.with_context_guard(...)`. Returns the service plus + /// the shared `FakeMemory` so a test can assert the persisted note. + fn build_service_with_guard( + contexts: FakeContexts, + ) -> (Arc, Arc) { + let memory = Arc::new(FakeMemory::default()); + let file_guard = Arc::new(RwFileGuard::new()) as Arc; + let context_guard = Arc::new(ContextGuardUseCases { + read_context: Arc::new(ReadContext::new( + Arc::clone(&file_guard), + Arc::new(contexts.clone()), + Arc::new(FakeFs), + )), + propose_context: Arc::new(ProposeContext::new( + Arc::clone(&file_guard), + Arc::new(contexts.clone()), + Arc::new(FakeFs), + Arc::new(FixedClock), + )), + read_memory: Arc::new(ReadMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory) as Arc, + )), + write_memory: Arc::new(WriteMemory::new( + Arc::clone(&file_guard), + Arc::clone(&memory) as Arc, + )), + }); + // Rewrap `build_service`'s service with the guard. `build_service` + // already produces a fully-wired `OrchestratorService` over the same + // fakes; `.with_context_guard` is additive, exactly the prod builder. + let service = build_service(contexts); + let service = Arc::try_unwrap(service) + .map_err(|_| ()) + .expect("freshly built service is uniquely owned") + .with_context_guard(context_guard); + (Arc::new(service), memory) + } + + /// The party an MCP agent presents to the guard — an agent (not the + /// orchestrator), the realistic caller of `idea_memory_*`/`idea_context_*`. + fn agent_party(n: u128) -> ConversationParty { + ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n))) + } + + // ----------------------------------------------------------------------- + // LS4 — live-state round-trip through the wired service. + // ----------------------------------------------------------------------- + + /// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip + /// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the + /// service's write/read providers without touching the filesystem. + #[derive(Default)] + struct InMemLiveStore { + state: Mutex, + } + #[async_trait] + impl domain::ports::LiveStateStore for InMemLiveStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } + } + + struct WsWriteProvider { + update: Arc, + } + impl application::LiveStateProvider for WsWriteProvider { + fn live_state_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.update)) + } + } + struct WsReadProvider { + getter: Arc, + } + impl application::LiveStateReadProvider for WsReadProvider { + fn live_state_lean_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.getter)) + } + } + + /// Like [`build_service_with_guard`] but additively wires the LS4 write+read + /// providers over a SHARED in-memory store, exactly the prod builder's + /// `.with_live_state(...).with_live_state_read(...)`. + fn build_service_with_live_state(contexts: FakeContexts) -> Arc { + let store = Arc::new(InMemLiveStore::default()) as Arc; + let clock = Arc::new(FixedClock) as Arc; + let write = Arc::new(WsWriteProvider { + update: Arc::new(application::UpdateLiveState::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let read = Arc::new(WsReadProvider { + getter: Arc::new(application::GetLiveStateLean::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let service = build_service(contexts); + let service = Arc::try_unwrap(service) + .map_err(|_| ()) + .expect("freshly built service is uniquely owned") + .with_live_state(write as Arc) + .with_live_state_read(read as Arc); + Arc::new(service) + } + + /// END-TO-END (lot LS4) — through the wired `OrchestratorService`, + /// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's + /// row comes back with the declared status/intent and the **resolved display name** + /// (via `ListAgents`), and `progress` never surfaces. + #[tokio::test] + async fn wired_serves_workstate_set_then_read_round_trip() { + let proj = project(); + let contexts = FakeContexts::new(); + let agent = contexts.seed_agent("architect"); + let service = build_service_with_live_state(contexts); + + // set — keyed on the agent identity; a progress note is supplied (must not leak). + let ack = service + .dispatch( + &proj, + domain::OrchestratorCommand::SetWorkState { + agent, + status: domain::live_state::WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("hidden note".to_owned()), + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set must succeed when live-state is wired"); + assert!(ack.reply.is_none(), "set is ACK only: {ack:?}"); + + // read — over the SAME store, the architect's row comes back resolved. + let out = service + .dispatch( + &proj, + domain::OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read must succeed when live-state is wired"); + let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap(); + let rows = json.as_array().expect("array"); + assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}"); + assert_eq!( + rows[0]["agent"], + json!("architect"), + "name resolved: {json}" + ); + assert_eq!(rows[0]["status"], json!("working")); + assert_eq!(rows[0]["intent"], json!("ship LS4")); + assert!( + rows[0].get("progress").is_none(), + "progress must never surface on read: {json}" + ); + } + + /// WIRING — with `.with_context_guard(...)`, `memory.write` then + /// `memory.read` round-trips the content instead of erroring "not + /// configured". Proves the four use cases reached the dispatch. + #[tokio::test] + async fn wired_guard_serves_memory_read_write_round_trip() { + let proj = project(); + let (service, _memory) = build_service_with_guard(FakeContexts::new()); + + // memory.write — must not return the "not configured" sentinel. + let write = service + .dispatch( + &proj, + OrchestratorCommand::WriteMemory { + slug: "wiring-note".to_owned(), + content: "guard is wired".to_owned(), + requester: agent_party(1), + }, + ) + .await + .expect("memory.write must succeed when the guard is wired"); + assert_eq!(write.detail, "wrote memory wiring-note"); + + // memory.read on the SAME shared guard/store — returns the body. + let read = service + .dispatch( + &proj, + OrchestratorCommand::ReadMemory { + slug: Some("wiring-note".to_owned()), + requester: agent_party(2), + }, + ) + .await + .expect("memory.read must succeed when the guard is wired"); + assert_eq!(read.reply.as_deref(), Some("guard is wired")); + } + + /// WIRING — `context.read` on a seeded agent target returns its `.md` body + /// (not the "not configured" error), exercising `ReadContext` end-to-end. + #[tokio::test] + async fn wired_guard_serves_context_read() { + let proj = project(); + let contexts = FakeContexts::new(); + let agent = contexts.seed_agent("dev-backend"); + // Seed the agent's context body via the store the use case reads from. + { + let md = contexts.md_path_of(&agent).unwrap(); + contexts + .0 + .lock() + .unwrap() + .contents + .insert(md, "# Dev Backend context".to_owned()); + } + let (service, _memory) = build_service_with_guard(contexts); + + let out = service + .dispatch( + &proj, + OrchestratorCommand::ReadContext { + target: Some("dev-backend".to_owned()), + requester: agent_party(3), + }, + ) + .await + .expect("context.read must succeed when the guard is wired"); + assert_eq!(out.reply.as_deref(), Some("# Dev Backend context")); + assert_eq!(out.detail, "read dev-backend context"); + } + + /// SYMMETRY — without the guard, the very same command yields the typed + /// `AppError::Invalid("FileGuard … not configured")`. Documents the contract + /// of `require_context_guard` and pins the exact regression message. + #[tokio::test] + async fn unwired_service_rejects_memory_write_with_typed_error() { + let proj = project(); + // `build_service` does NOT call `.with_context_guard`. + let service = build_service(FakeContexts::new()); + + let err = service + .dispatch( + &proj, + OrchestratorCommand::WriteMemory { + slug: "wiring-note".to_owned(), + content: "x".to_owned(), + requester: agent_party(1), + }, + ) + .await + .expect_err("memory.write must fail without the guard wired"); + assert_eq!( + err, + application::AppError::Invalid( + "FileGuard context/memory tools are not configured".to_owned() + ) + ); + } +} + +#[cfg(test)] +mod tests { + //! Wiring contract for [`build_memory_recall`] (Pièce 1, §14.5.5). + //! + //! The *behaviour* of `NaiveMemoryRecall` / `VectorMemoryRecall` / + //! `AdaptiveMemoryRecall` is covered exhaustively in + //! `infrastructure/tests/vector_recall.rs`. Here we only pin the **composition + //! root contract**: with the default `EmbedderProfile::none()` profile (the + //! dependency-free default), `build_memory_recall` must hand back a recall whose + //! observable behaviour is *identical* to a bare `NaiveMemoryRecall` over the + //! same store — same index ordering, same budget truncation. + //! + //! `build_memory_recall` is `pub(crate)`, so this lives in `state.rs` (it is not + //! reachable from the `tests/` integration crate). Everything is in-memory and + //! deterministic. + + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + use async_trait::async_trait; + use domain::markdown::MarkdownDoc; + use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; + use domain::ports::{DirEntry, FsError, MemoryQuery, RemotePath}; + use domain::project::ProjectPath; + + // In-memory FileSystem (same minimal shape as the infrastructure test fixtures). + #[derive(Default)] + struct MemFs { + files: Mutex>>, + } + + #[async_trait] + impl FileSystem for MemFs { + async fn read(&self, path: &RemotePath) -> Result, FsError> { + self.files + .lock() + .unwrap() + .get(path.as_str()) + .cloned() + .ok_or_else(|| FsError::NotFound(path.as_str().to_string())) + } + async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { + self.files + .lock() + .unwrap() + .insert(path.as_str().to_string(), data.to_vec()); + Ok(()) + } + async fn exists(&self, path: &RemotePath) -> Result { + Ok(self.files.lock().unwrap().contains_key(path.as_str())) + } + async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _path: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + } + + fn root() -> ProjectPath { + ProjectPath::new("/proj").unwrap() + } + + fn note(slug: &str, hook: &str) -> Memory { + Memory::new( + MemoryFrontmatter { + name: MemorySlug::new(slug).unwrap(), + description: hook.to_string(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new("# body"), + ) + .unwrap() + } + + fn query(text: &str, budget: usize) -> MemoryQuery { + MemoryQuery { + text: text.to_string(), + token_budget: budget, + } + } + + async fn seed_store() -> (Arc, Arc) { + let fs: Arc = Arc::new(MemFs::default()); + let store_concrete = Arc::new(FsMemoryStore::new(Arc::clone(&fs))); + for n in [ + note("alpha", "apple orange grape"), + note("beta", "kiwi mango papaya"), + note("gamma", "carrot potato onion"), + ] { + store_concrete.save(&root(), &n).await.unwrap(); + } + let store: Arc = store_concrete; + (fs, store) + } + + /// With the default `none` profile, `build_memory_recall` is observationally a + /// plain `NaiveMemoryRecall`: same index ordering and same budget truncation, + /// entry-for-entry, across a representative spread of budgets. + #[tokio::test] + async fn build_memory_recall_none_profile_matches_naive_recall() { + let (fs, store) = seed_store().await; + + let wired = build_memory_recall( + Arc::clone(&fs), + Arc::clone(&store), + &EmbedderProfile::none(), + std::path::Path::new("/unused-onnx-cache"), + ); + let naive = NaiveMemoryRecall::new(Arc::clone(&store)); + + // 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order. + for budget in [0usize, 3, 6, 100, 100_000] { + let q = query("kiwi mango papaya", budget); + let expected = naive.recall(&root(), &q).await.unwrap(); + let got = wired.recall(&root(), &q).await.unwrap(); + assert_eq!( + got, expected, + "none profile must equal bare NaiveMemoryRecall at budget {budget}" + ); + } + } +} + +// =========================================================================== +// M5e — Smoke end-to-end over the REAL loopback (no real CLI, no network). +// =========================================================================== + +#[cfg(test)] +mod mcp_e2e_loopback_tests { + //! M5e — **smoke end-to-end** of the bind transport over a **real** + //! `interprocess` loopback (cadrage v5 §6 row M5e, §2 contract). + //! + //! ## Chosen e2e level — the **real accept loop**, justified + //! + //! Unlike M5c (which drove the *private* `serve_peer` over an in-memory + //! `tokio::io::duplex`), M5e proves the chain across an **actual** local socket. + //! Two options were on the table (cadrage M5e): + //! + //! 1. the **full `McpServerHandle::start` accept loop** on a real + //! `mcp_endpoint`, dialed by a real `interprocess` client, or + //! 2. a single `serve_peer` over one accepted real socket. + //! + //! We pick **(1) the real accept loop**. It is the *most faithful* slice — it + //! exercises exactly the production path a launched CLI's `idea mcp-server` + //! bridge takes: `bind_endpoint` → `McpServerHandle::start` → `listener.accept()` + //! → `serve_peer` (handshake + project guard) → `StdioTransport` → + //! `McpServer::serve_as` → the **real `dispatch`** (over the same in-memory fakes + //! as M2/M5c). And it is **safely bounded**: the accept loop is already async and + //! parks on the absence of a peer, while the *client* side is a plain + //! request/response exchange we wrap in `tokio::time::timeout`. Option (2) would + //! skip the bind/accept/lifecycle wiring for no extra safety, since the risk + //! (a peer that never speaks) is bounded identically by the client-side timeout. + //! + //! No production test seam was added: `McpServerHandle::start`, `bind_endpoint` + //! and `mcp_endpoint` are all reachable from this in-crate module as-is. The + //! client uses the same `interprocess` `Stream` the real bridge uses. + //! + //! GARDE-FOU : the listener bind, every client connect, and every response read + //! is wrapped in `tokio::time::timeout`; a hung accept/serve/handshake fails the + //! test fast instead of hanging the suite. + + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use async_trait::async_trait; + use interprocess::local_socket::tokio::Stream as LocalSocketStream; + use interprocess::local_socket::traits::tokio::Stream as _; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use uuid::Uuid; + + use application::{ + CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, + OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, + }; + use domain::agent::{AgentManifest, ManifestEntry}; + use domain::events::{DomainEvent, OrchestrationSource}; + use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId}; + use domain::markdown::MarkdownDoc; + use domain::ports::{ + AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan, + DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, + OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, + ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + }; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, + }; + use domain::project::{Project, ProjectPath}; + use domain::remote::RemoteRef; + use domain::skill::{Skill, SkillScope}; + use domain::{PtySize, SessionId}; + use serde_json::{json, Value}; + + use super::{bind_endpoint, mcp_endpoint, McpServerHandle}; + use crate::mcp_endpoint::McpEndpoint; + use infrastructure::{ + InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock, + }; + + /// Test timeout for any single loopback interaction. Generous but finite: a + /// correct round-trip is sub-millisecond, so this only ever fires on a real hang. + const TIMEOUT: Duration = Duration::from_secs(5); + + // ----------------------------------------------------------------------- + // Fakes — mirrored from `infrastructure/tests/mcp_server.rs` (the established + // MCP harness). Includes a `FakeSession` so `idea_ask_agent` resolves inline. + // ----------------------------------------------------------------------- + + #[derive(Default)] + struct ContextsInner { + manifest: AgentManifest, + contents: HashMap, + } + #[derive(Clone)] + struct FakeContexts(Arc>); + impl FakeContexts { + fn new() -> Self { + Self(Arc::new(Mutex::new(ContextsInner { + manifest: AgentManifest { + version: 1, + entries: Vec::new(), + orchestrator: None, + }, + contents: HashMap::new(), + }))) + } + fn seed_agent(&self, name: &str) -> AgentId { + let id = AgentId::from_uuid(Uuid::new_v4()); + let mut inner = self.0.lock().unwrap(); + inner.manifest.entries.push(ManifestEntry { + agent_id: id, + name: name.to_owned(), + md_path: format!("agents/{name}.md"), + profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), + template_id: None, + synchronized: false, + synced_template_version: None, + skills: Vec::new(), + }); + id + } + fn md_path_of(&self, agent: &AgentId) -> Option { + self.0 + .lock() + .unwrap() + .manifest + .entries + .iter() + .find(|e| &e.agent_id == agent) + .map(|e| e.md_path.clone()) + } + } + #[async_trait] + impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + agent: &AgentId, + ) -> Result { + let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + Ok(MarkdownDoc::new( + self.0 + .lock() + .unwrap() + .contents + .get(&md) + .cloned() + .unwrap_or_default(), + )) + } + async fn write_context( + &self, + _project: &Project, + agent: &AgentId, + md: &MarkdownDoc, + ) -> Result<(), StoreError> { + let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; + self.0 + .lock() + .unwrap() + .contents + .insert(path, md.as_str().to_owned()); + Ok(()) + } + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.0.lock().unwrap().manifest.clone()) + } + async fn save_manifest( + &self, + _project: &Project, + manifest: &AgentManifest, + ) -> Result<(), StoreError> { + self.0.lock().unwrap().manifest = manifest.clone(); + Ok(()) + } + } + + #[derive(Clone)] + struct FakeProfiles(Arc>); + #[async_trait] + impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, StoreError> { + Ok((*self.0).clone()) + } + async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> { + Ok(()) + } + async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { + Ok(()) + } + async fn is_configured(&self) -> Result { + Ok(true) + } + async fn mark_configured(&self) -> Result<(), StoreError> { + Ok(()) + } + } + + #[derive(Default)] + struct FakeSkills; + #[async_trait] + impl SkillStore for FakeSkills { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { + Ok(Vec::new()) + } + async fn get( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result { + Err(StoreError::NotFound) + } + async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { + Ok(()) + } + async fn delete( + &self, + _scope: SkillScope, + _root: &ProjectPath, + _id: SkillId, + ) -> Result<(), StoreError> { + Ok(()) + } + } + + #[derive(Default)] + struct FakeRecall; + #[async_trait] + impl domain::ports::MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + _query: &domain::ports::MemoryQuery, + ) -> Result, domain::ports::MemoryError> { + Ok(Vec::new()) + } + } + + struct FakeSession { + id: SessionId, + reply: String, + } + + #[async_trait] + impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + Some(self.id.to_string()) + } + + async fn send(&self, _prompt: &str) -> Result { + Ok(Box::new(std::iter::once(ReplyEvent::Final { + content: self.reply.clone(), + }))) + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } + } + + struct FakeRuntime; + #[async_trait] + impl AgentRuntime for FakeRuntime { + async fn detect(&self, _p: &AgentProfile) -> Result { + Ok(true) + } + fn prepare_invocation( + &self, + profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + Ok(SpawnSpec { + command: profile.command.clone(), + args: profile.args.clone(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, + }) + } + } + + #[derive(Clone, Default)] + struct FakeFs; + #[async_trait] + impl FileSystem for FakeFs { + async fn read(&self, p: &RemotePath) -> Result, FsError> { + Err(FsError::NotFound(p.as_str().to_owned())) + } + async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { + Ok(()) + } + async fn exists(&self, _p: &RemotePath) -> Result { + Ok(false) + } + async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + async fn list(&self, _p: &RemotePath) -> Result, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { + Ok(()) + } + } + + #[derive(Clone)] + struct FakePty; + #[async_trait] + impl PtyPort for FakePty { + async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { + Ok(PtyHandle { + session_id: SessionId::from_uuid(Uuid::from_u128(777)), + }) + } + fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { + Ok(()) + } + fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { + Ok(()) + } + fn subscribe_output(&self, _h: &PtyHandle) -> Result { + Ok(Box::new(std::iter::empty())) + } + fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { + Ok(Vec::new()) + } + async fn wait(&self, _h: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } + fn try_wait(&self, _h: &PtyHandle) -> Result, PtyError> { + Ok(Some(ExitStatus { code: Some(0) })) + } + async fn kill(&self, _h: &PtyHandle) -> Result { + Ok(ExitStatus { code: Some(0) }) + } + } + + #[derive(Default, Clone)] + struct NoopBus; + impl EventBus for NoopBus { + fn publish(&self, _e: DomainEvent) {} + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } + } + + struct SeqIds(Mutex); + impl IdGenerator for SeqIds { + fn new_uuid(&self) -> Uuid { + let mut n = self.0.lock().unwrap(); + let id = Uuid::from_u128(*n); + *n += 1; + id + } + } + + fn project() -> Project { + // A fresh project id per call ⇒ a distinct endpoint per test (no socket-file + // collision when tests run in parallel). + Project::new( + ProjectId::from_uuid(Uuid::new_v4()), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() + } + + fn project_id_arg(p: &Project) -> String { + p.id.as_uuid().simple().to_string() + } + + fn capturing_events() -> ( + Arc, + Arc>>, + ) { + let captured = Arc::new(Mutex::new(Vec::new())); + let sink = captured.clone(); + let publish: Arc = + Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e)); + (publish, captured) + } + + /// Builds an `OrchestratorService` over the in-memory fakes, returning the + /// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an + /// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending + /// tickets). Mirrors the established harness; no use case is re-invented. + fn build_service( + contexts: FakeContexts, + ) -> ( + Arc, + Arc, + Arc, + ) { + // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : + // seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour + // `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible. + let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::stdin(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + ))]))); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + seed_structured_sessions(&structured, &contexts); + let mailbox = Arc::new(InMemoryMailbox::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + let input = Arc::new(MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; + let service = OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + input, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_structured(structured); + (Arc::new(service), sessions, mailbox) + } + + /// Codex twin of [`build_service`]: identical wiring, but the single profile + /// targets the **Codex** structured adapter with a `TomlConfigHome` MCP strategy + /// (`$CODEX_HOME/config.toml`). This is the couple the bridge guard + /// (`materializes_idea_bridge`) must now let through — proving Codex is eligible + /// for `idea_ask_agent`, exactly like Claude. No real `codex` binary is ever + /// spawned: the runtime/PTY are the same in-memory fakes. + fn build_service_codex( + contexts: FakeContexts, + ) -> ( + Arc, + Arc, + Arc, + ) { + let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Codex CLI", + "codex", + Vec::new(), + ContextInjection::stdin(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(), + McpTransport::Stdio, + ))]))); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + seed_structured_sessions(&structured, &contexts); + let mailbox = Arc::new(InMemoryMailbox::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + let input = Arc::new(MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; + let service = OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + input, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_structured(structured); + (Arc::new(service), sessions, mailbox) + } + + fn seed_structured_sessions(sessions: &StructuredSessions, contexts: &FakeContexts) { + let entries = contexts.0.lock().unwrap().manifest.entries.clone(); + for (index, entry) in entries.into_iter().enumerate() { + let session = Arc::new(FakeSession { + id: SessionId::from_uuid(Uuid::from_u128(10_000 + index as u128)), + reply: "the answer is 42".to_owned(), + }); + sessions.insert( + session, + entry.agent_id, + NodeId::from_uuid(Uuid::from_u128(20_000 + index as u128)), + ); + } + } + + // --- real loopback harness --------------------------------------------- + + /// Stands up the **real** production accept-and-serve path on a real + /// `interprocess` loopback for `project`: binds the endpoint via the production + /// `bind_endpoint`, starts `McpServerHandle::start` (the M5c accept loop), and + /// returns the live handle plus the endpoint to dial. + /// + /// Asserts the bind actually succeeded (a `None` listener would make the e2e + /// vacuous) — if the per-user runtime dir is unwritable the test fails loudly + /// rather than silently testing nothing. + fn start_real_server( + service: Arc, + project: &Project, + events: Option>, + ) -> (McpServerHandle, McpEndpoint) { + let endpoint = mcp_endpoint(&project.id); + let listener = bind_endpoint(&endpoint); + assert!( + listener.is_some(), + "M5e needs a real bound listener; bind_endpoint returned None for {:?}", + endpoint.as_cli_arg() + ); + let mut server = McpServer::new(service, project.clone()); + if let Some(publish) = events { + server = server.with_events(publish); + } + let handle = + McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project)); + (handle, endpoint) + } + + /// Connects a **real** `interprocess` client to `endpoint`, bounded by [`TIMEOUT`] + /// so an endpoint that never accepts fails fast (GARDE-FOU). + async fn connect_client(endpoint: &McpEndpoint) -> LocalSocketStream { + use interprocess::local_socket::{GenericFilePath, ToFsName as _}; + let name = endpoint + .as_cli_arg() + .to_fs_name::() + .expect("valid endpoint name"); + tokio::time::timeout(TIMEOUT, LocalSocketStream::connect(name)) + .await + .expect("GARDE-FOU: connect to endpoint timed out") + .expect("connect to a live endpoint") + } + + fn handshake_line(project: &str, requester: &str) -> String { + format!("{{\"project\":\"{project}\",\"requester\":\"{requester}\"}}\n") + } + + fn tools_call_line(id: i64, tool: &str, arguments: Value) -> String { + let mut s = serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": tool, "arguments": arguments } + })) + .unwrap(); + s.push('\n'); + s + } + + /// Reads exactly one newline-delimited JSON-RPC response off the client side of + /// the real socket, bounded by [`TIMEOUT`]. `None` on EOF/timeout. + async fn read_one_response(reader: &mut R) -> Option + where + R: AsyncBufReadExt + Unpin, + { + let mut line = String::new(); + match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await { + Ok(Ok(0)) => None, // EOF before a full line + Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(), + Ok(Err(_)) => None, + Err(_) => None, // GARDE-FOU: timed out waiting for a reply + } + } + + // ----------------------------------------------------------------------- + // 1. idea_list_agents e2e over the real loopback ⇒ JSON array of agents. + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn list_agents_round_trips_over_real_loopback() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + contexts.seed_agent("dev-backend"); + let (service, _sessions, _mailbox) = build_service(contexts); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + let conn = connect_client(&endpoint).await; + let (read_half, mut write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + + // Handshake (real project id) + a tools/call idea_list_agents. + write_half + .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) + .await + .unwrap(); + write_half + .write_all(tools_call_line(1, "idea_list_agents", json!({})).as_bytes()) + .await + .unwrap(); + write_half.flush().await.unwrap(); + + let resp = read_one_response(&mut reader) + .await + .expect("a list_agents response line"); + assert_eq!(resp["id"], json!(1)); + assert!(resp["error"].is_null(), "transport error: {resp}"); + let result = &resp["result"]; + assert_eq!(result["isError"], json!(false), "got {result}"); + + // The inline text is the JSON array of the project's agents. + let text = result["content"][0]["text"].as_str().expect("text block"); + let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array"); + let arr = agents.as_array().expect("array"); + assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}"); + let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"architect"), "got {names:?}"); + assert!(names.contains(&"dev-backend"), "got {names:?}"); + + drop(write_half); // EOF ⇒ serve loop ends cleanly + handle.stop(); + } + + // ----------------------------------------------------------------------- + // 2. `idea_ask_agent` e2e over the real loopback under the current headless + // protocol: the orchestrator drives the target's structured session directly + // and returns the captured `Final` inline. No target-side `idea_reply` tool is + // involved. + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn ask_then_reply_round_trips_inline_over_real_loopback() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, _sessions, mailbox) = build_service(contexts); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + // Connection A: the asker. + let conn_a = connect_client(&endpoint).await; + let (read_a, mut write_a) = tokio::io::split(conn_a); + let mut reader_a = BufReader::new(read_a); + write_a + .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) + .await + .unwrap(); + write_a + .write_all( + tools_call_line( + 7, + "idea_ask_agent", + json!({ "target": "architect", "task": "What is the answer?" }), + ) + .as_bytes(), + ) + .await + .unwrap(); + write_a.flush().await.unwrap(); + + // The asker receives the structured target's Final inline. + let resp = read_one_response(&mut reader_a) + .await + .expect("an ask response line"); + assert_eq!(resp["id"], json!(7)); + assert!(resp["error"].is_null(), "transport error: {resp}"); + let result = &resp["result"]; + assert_eq!(result["isError"], json!(false), "got {result}"); + assert_eq!( + result["content"][0]["text"].as_str().expect("text block"), + "the answer is 42", + "ask reply must be returned inline over the real loopback; got {result}" + ); + assert_eq!( + mailbox.pending(&agent_id), + 0, + "structured ask must drain its accounting ticket" + ); + + drop(write_a); + handle.stop(); + } + + // ----------------------------------------------------------------------- + // 2b. Codex twin of the round-trip above: the *only* difference is the target + // profile (Codex + TomlConfigHome MCP). It proves the bridge guard now lets + // a Codex target through AND that the inline ask→reply loop still completes. + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn ask_then_reply_round_trips_inline_over_real_loopback_codex() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, _sessions, mailbox) = build_service_codex(contexts); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + // Connection A: the asker. + let conn_a = connect_client(&endpoint).await; + let (read_a, mut write_a) = tokio::io::split(conn_a); + let mut reader_a = BufReader::new(read_a); + write_a + .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) + .await + .unwrap(); + write_a + .write_all( + tools_call_line( + 7, + "idea_ask_agent", + json!({ "target": "architect", "task": "What is the answer?" }), + ) + .as_bytes(), + ) + .await + .unwrap(); + write_a.flush().await.unwrap(); + + // The asker receives the structured target's Final inline. + let resp = read_one_response(&mut reader_a) + .await + .expect("an ask response line"); + assert_eq!(resp["id"], json!(7)); + assert!(resp["error"].is_null(), "transport error: {resp}"); + let result = &resp["result"]; + assert_eq!(result["isError"], json!(false), "got {result}"); + assert_eq!( + result["content"][0]["text"].as_str().expect("text block"), + "the answer is 42", + "ask reply must be returned inline over the real loopback (Codex target); got {result}" + ); + assert_eq!( + mailbox.pending(&agent_id), + 0, + "structured ask must drain its accounting ticket" + ); + + drop(write_a); + handle.stop(); + } + + // ----------------------------------------------------------------------- + // 3. `idea_reply` is no longer part of the MCP surface. Calling it returns a + // JSON-RPC unknown-tool error and the connection stays healthy for a follow-up + // call. + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn removed_reply_tool_is_unknown_over_real_loopback() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("dev"); + let (service, _sessions, _mailbox) = build_service(contexts); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + let conn = connect_client(&endpoint).await; + let (read_half, mut write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + + // The peer identifies as `agent_id`; `idea_reply` itself is no longer mapped. + write_half + .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes()) + .await + .unwrap(); + write_half + .write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes()) + .await + .unwrap(); + write_half.flush().await.unwrap(); + + let resp = read_one_response(&mut reader) + .await + .expect("a reply response line"); + assert_eq!(resp["id"], json!(3)); + assert_eq!(resp["error"]["code"], json!(-32601), "got {resp}"); + assert_eq!( + resp["error"]["message"], + json!("unknown tool: idea_reply"), + "got {resp}" + ); + assert!(resp.get("result").is_none(), "got {resp}"); + + // The connection is still healthy: a follow-up tools/list still answers. + write_half + .write_all( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", "id": 4, "method": "tools/list" + })) + .map(|mut s| { + s.push('\n'); + s + }) + .unwrap() + .as_bytes(), + ) + .await + .unwrap(); + write_half.flush().await.unwrap(); + let again = read_one_response(&mut reader) + .await + .expect("server still serves after a tool error"); + assert_eq!(again["id"], json!(4)); + assert!(again["result"]["tools"].is_array(), "got {again}"); + + drop(write_half); + handle.stop(); + } + + // ----------------------------------------------------------------------- + // 4. Malformed JSON-RPC after the handshake ⇒ JSON-RPC error, never a panic, + // the server stays alive for a subsequent valid call. + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let (service, _sessions, _mailbox) = build_service(contexts); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + let conn = connect_client(&endpoint).await; + let (read_half, mut write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + + write_half + .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) + .await + .unwrap(); + // A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop. + write_half.write_all(b"{ this is not json\n").await.unwrap(); + write_half.flush().await.unwrap(); + + let resp = read_one_response(&mut reader) + .await + .expect("a parse error still owes a response"); + let error = &resp["error"]; + assert!( + !error.is_null(), + "malformed input must yield a JSON-RPC error: {resp}" + ); + // JSON-RPC mandates a null id when the request could not be correlated. + assert_eq!(resp["id"], Value::Null, "got {resp}"); + assert!( + resp["result"].is_null(), + "no result on parse error; got {resp}" + ); + + // The server survived: a subsequent valid call still answers. + write_half + .write_all( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list" + })) + .map(|mut s| { + s.push('\n'); + s + }) + .unwrap() + .as_bytes(), + ) + .await + .unwrap(); + write_half.flush().await.unwrap(); + let again = read_one_response(&mut reader) + .await + .expect("server must survive malformed input"); + assert_eq!(again["id"], json!(2)); + assert!(again["result"]["tools"].is_array(), "got {again}"); + + drop(write_half); + handle.stop(); + } + + // ----------------------------------------------------------------------- + // 5. Bonus — the handshake requester crosses the REAL loopback and tags the + // OrchestratorRequestProcessed event with the real agent id (not "mcp"). + // ----------------------------------------------------------------------- + + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn handshake_requester_propagates_over_real_loopback() { + let contexts = FakeContexts::new(); + let (service, _sessions, _mailbox) = build_service(contexts); + let (publish, captured) = capturing_events(); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, Some(publish)); + + let conn = connect_client(&endpoint).await; + let (read_half, mut write_half) = tokio::io::split(conn); + let mut reader = BufReader::new(read_half); + + // Handshake carries the real requesting agent id. + write_half + .write_all(handshake_line(&project_id_arg(&proj), "agent-42").as_bytes()) + .await + .unwrap(); + // A successful launch (creates + launches an agent from scratch) ⇒ a + // processed beacon is emitted, tagged with the handshake requester. + write_half + .write_all( + tools_call_line( + 1, + "idea_launch_agent", + json!({ "target": "dev-backend", "profile": "claude-code" }), + ) + .as_bytes(), + ) + .await + .unwrap(); + write_half.flush().await.unwrap(); + + let resp = read_one_response(&mut reader) + .await + .expect("a launch response line"); + assert!(resp["error"].is_null(), "transport error: {resp}"); + assert_eq!(resp["result"]["isError"], json!(false), "got {resp}"); + + // Drain the connection so the serve task has surely published the event. + drop(write_half); + // Give the spawned serve task a beat to flush its publish (bounded). + let _ = tokio::time::timeout(TIMEOUT, async { + loop { + if captured + .lock() + .unwrap() + .iter() + .any(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await; + + let events = captured.lock().unwrap(); + let processed: Vec<&DomainEvent> = events + .iter() + .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) + .collect(); + assert_eq!( + processed.len(), + 1, + "expected exactly one processed event; got {events:?}" + ); + match processed[0] { + DomainEvent::OrchestratorRequestProcessed { + requester_id, + action, + ok, + source, + } => { + assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp"); + assert_eq!(action, "idea_launch_agent"); + assert!(*ok, "the launch succeeded"); + assert_eq!( + requester_id, "agent-42", + "the real handshake requester must cross the loopback (not 'mcp')" + ); + } + other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), + } + drop(events); + + handle.stop(); + } +} + +#[cfg(test)] +mod bind_endpoint_d1_tests { + //! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim + //! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`"). + //! + //! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain + //! `bind` would then fail with `EADDRINUSE`. The production path passes + //! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests + //! pin that behaviour so a future refactor cannot silently drop the flag and + //! resurrect the "address already in use" failure on restart. + //! + //! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to + //! reclaim, so there is nothing to assert. + + #![cfg(unix)] + + use super::{bind_endpoint, mcp_endpoint}; + use domain::ProjectId; + use uuid::Uuid; + + /// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL) + /// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first. + /// + /// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener` + /// binds the path then is dropped — std does **not** unlink on drop, so the socket + /// inode is left on the filesystem with **no live listener**, exactly the state a + /// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind` + /// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)` + /// must unlink it first. + #[ignore = "requires local socket bind permission"] + #[tokio::test] + async fn rebind_after_corpse_socket_succeeds() { + use std::os::unix::net::UnixListener; + + let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001))); + let path = ep + .socket_path() + .expect("unix endpoint exposes a socket path"); + + // Clean any leftover from a previous run of this very test. + let _ = std::fs::remove_file(&path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + // 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and + // drop it ⇒ socket file remains with no live listener (SIGKILL aftermath). + { + let corpse = UnixListener::bind(&path).expect("lay corpse socket"); + drop(corpse); + } + assert!( + path.exists(), + "corpse socket inode remains (no live listener)" + ); + + // 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT + // fail with EADDRINUSE. + let first = bind_endpoint(&ep); + assert!( + first.is_some(), + "rebind over the corpse socket must succeed (reclaim_name unlinks it)" + ); + assert!(path.exists(), "a fresh live socket now sits at the path"); + + // 3) Idempotent over a live socket: a second bind also reclaims + succeeds. + let second = bind_endpoint(&ep); + assert!(second.is_some(), "bind is idempotent across a live socket"); + + // 4) No file leak after a clean close: dropping the live listener(s) unlinks + // the socket (interprocess reclaim guard on drop). + drop(first); + drop(second); + assert!( + !path.exists(), + "socket file unlinked on clean close (no leak)" + ); + } +} diff --git a/crates/backend/src/mcp_endpoint.rs b/crates/backend/src/mcp_endpoint.rs new file mode 100644 index 0000000..06422ee --- /dev/null +++ b/crates/backend/src/mcp_endpoint.rs @@ -0,0 +1,317 @@ +//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a). +//! +//! ## Where this sits in the bind (cadrage v5 §1, §2) +//! +//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex) +//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server` +//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays +//! every line, over a **local loopback**, to the IdeA desktop/server process where the +//! real [`OrchestratorService`](application::OrchestratorService) lives. The +//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named +//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and +//! needs no firewall/permission. +//! +//! This module owns the **single source of truth** for that loopback address: +//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by +//! **both** sides of the contract (cadrage §2): +//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server), +//! which binds the listener at project-open and tears it down at close (M5a); +//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d), +//! which must point the spawned bridge at the **exact** same address. +//! +//! Keeping the derivation here (not duplicated on each side) is what makes the +//! M1↔M3 coherence test possible. +//! +//! ## Transport crate choice — `interprocess` +//! +//! We use the [`interprocess`] crate's *local socket* abstraction rather than +//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because: +//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single +//! `accept` loop in M5c, no divergent `cfg` branches with two transport types. +//! - The workspace `tokio` is built **without** the `net` feature; pulling +//! `interprocess` (with its `tokio` feature) into *only* this crate avoids +//! widening tokio's surface workspace-wide. +//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket +//! file on drop** — so closing a project (dropping the handle) cleans the +//! filesystem with no leak, satisfying M5a's teardown requirement for free. +//! +//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user +//! runtime dir) rather than an abstract/namespaced one, so that the existence of +//! the endpoint is observable as a real path (testable) and cleaned up on close. + +use std::path::PathBuf; + +use application::{McpRuntime, McpRuntimeProvider}; +use domain::{AgentId, Project, ProjectId}; + +/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`] +/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by +/// the listener (M5a/M3) and the CLI-declaration writer (M5d). +/// +/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what +/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of +/// the form `\\.\pipe\idea-mcp-`. The [`as_cli_arg`](Self::as_cli_arg) form is +/// the exact string handed to the spawned bridge via `--endpoint`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpEndpoint { + /// The platform-native address string (UDS path / named-pipe path). + addr: String, +} + +impl McpEndpoint { + /// The address as the bridge expects it on the command line (`--endpoint`). + /// Identical string on both sides of the contract (cadrage §2). + #[must_use] + pub fn as_cli_arg(&self) -> &str { + &self.addr + } + + /// The Unix socket **file path**, when this endpoint is a filesystem-path UDS. + /// Used by the listener to ensure the parent runtime dir exists and by tests to + /// assert creation/cleanup. `None` on platforms whose address is not a path + /// (Windows named pipes have no filesystem entry to manage). + #[must_use] + pub fn socket_path(&self) -> Option { + #[cfg(unix)] + { + Some(PathBuf::from(&self.addr)) + } + #[cfg(not(unix))] + { + None + } + } +} + +/// The directory under which per-project Unix sockets live, derived from the +/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then +/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte +/// `sockaddr_un` limit (`/run/user//idea-mcp/<32 hex>.sock` ≈ 50 bytes). +/// +/// The candidate bases are tried **in priority order**, returning the first whose +/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR` +/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not +/// dead-end the loopback bind: without this fallback the socket silently never +/// binds and inter-agent delegation dies. Deterministic for a given environment, so +/// the bind side and any `socket_path()` reader always agree on the same directory. +#[cfg(unix)] +fn unix_runtime_dir() -> PathBuf { + #[cfg(test)] + { + let dir = std::env::temp_dir().join("idea-mcp"); + if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() { + return dir; + } + } + + let candidates = [ + std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), + std::env::var_os("TMPDIR").map(PathBuf::from), + Some(PathBuf::from("/tmp")), + ]; + for base in candidates.into_iter().flatten() { + let dir = base.join("idea-mcp"); + if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() { + return dir; + } + } + // Last resort: keep the historical `/tmp` target even if creation just failed + // (the bind will surface the real error rather than silently picking nowhere). + PathBuf::from("/tmp").join("idea-mcp") +} + +/// **Single source of truth.** Computes the deterministic loopback endpoint of a +/// project's MCP transport from its [`ProjectId`]. +/// +/// Same input ⇒ same output (stable across calls and processes); distinct projects +/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free +/// by construction). No network port is ever used. +/// +/// - **Unix**: `/idea-mcp/.sock` — a UDS file path. +/// - **Windows**: `\\.\pipe\idea-mcp-` — a named pipe. +/// +/// where `` is the project UUID in hyphen-free hex (`simple`) form. +#[must_use] +pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint { + // Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and + // a `\\.\pipe\` name, and unique per project. + let id = project_id.as_uuid().simple().to_string(); + + #[cfg(unix)] + let addr = unix_runtime_dir() + .join(format!("{id}.sock")) + .to_string_lossy() + .into_owned(); + + #[cfg(windows)] + let addr = format!(r"\\.\pipe\idea-mcp-{id}"); + + #[cfg(not(any(unix, windows)))] + let addr = format!("idea-mcp-{id}"); + + McpEndpoint { addr } +} + +/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP. +/// +/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux +/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère +/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP. +/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si +/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. +pub(crate) fn idea_exe_path() -> Option { + std::env::var("APPIMAGE").ok().or_else(|| { + std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + }) +} + +/// Implementation of the [`McpRuntimeProvider`] port: fournit à +/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour +/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin +/// `ask` (`ensure_live_pty`). +/// +/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de +/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime +/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` / +/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes** +/// traversent la frontière via [`McpRuntime`]. +pub struct AppMcpRuntimeProvider; + +impl McpRuntimeProvider for AppMcpRuntimeProvider { + /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera + /// `idea_reply`). Réutilise la **même** logique que le chemin GUI + /// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique), + /// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒ + /// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de + /// lancement. + fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option { + Some(McpRuntime { + exe: idea_exe_path()?, + endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn pid(s: &str) -> ProjectId { + ProjectId::from_uuid(Uuid::parse_str(s).unwrap()) + } + + #[test] + fn endpoint_is_deterministic_for_the_same_project() { + let p = pid("11111111-1111-1111-1111-111111111111"); + assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p)); + assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg()); + } + + #[test] + fn distinct_projects_get_distinct_endpoints() { + let a = pid("11111111-1111-1111-1111-111111111111"); + let b = pid("22222222-2222-2222-2222-222222222222"); + assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b)); + assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg()); + } + + #[test] + fn address_encodes_the_project_id_without_hyphens() { + let p = pid("abcdef01-2345-6789-abcd-ef0123456789"); + let arg = mcp_endpoint(&p).as_cli_arg().to_owned(); + assert!( + arg.contains("abcdef0123456789abcdef0123456789"), + "endpoint must embed the hyphen-free id, got {arg}" + ); + } + + #[cfg(unix)] + #[test] + fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() { + let p = pid("11111111-1111-1111-1111-111111111111"); + let ep = mcp_endpoint(&p); + let path = ep + .socket_path() + .expect("unix endpoint exposes a socket path"); + assert!(path.extension().is_some_and(|e| e == "sock")); + assert_eq!(path.parent().unwrap(), unix_runtime_dir()); + } + + /// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du + /// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur + /// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour + /// éviter une course inter-tests sur la variable d'env globale du process ; la var + /// est sauvegardée/restaurée pour ne pas polluer les autres tests. + #[test] + fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() { + let saved = std::env::var_os("APPIMAGE"); + + // APPIMAGE posé ⇒ valeur exacte renvoyée. + std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage"); + assert_eq!( + idea_exe_path().as_deref(), + Some("/opt/idea/IdeA.AppImage"), + "$APPIMAGE doit primer" + ); + + // APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test). + std::env::remove_var("APPIMAGE"); + let fallback = idea_exe_path(); + let expected = std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()); + assert_eq!(fallback, expected, "repli attendu sur current_exe()"); + assert!(fallback.is_some(), "current_exe() résolvable dans le test"); + + // Restauration de l'état initial de la variable. + match saved { + Some(v) => std::env::set_var("APPIMAGE", v), + None => std::env::remove_var("APPIMAGE"), + } + } + + /// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de + /// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`, + /// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`. + #[test] + fn app_provider_runtime_for_matches_sources_of_truth() { + use domain::project::ProjectPath; + use domain::remote::RemoteRef; + use domain::{AgentId, Project, ProjectId}; + + let project = Project::new( + ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()), + "demo", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap(); + let agent_id = AgentId::from_uuid(Uuid::from_u128(42)); + + // On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None). + let rt = AppMcpRuntimeProvider + .runtime_for(&project, agent_id) + .expect("runtime_for doit produire un McpRuntime (exe résolvable)"); + + // Endpoint = même source de vérité que le listener. + assert_eq!( + rt.endpoint, + mcp_endpoint(&project.id).as_cli_arg(), + "endpoint cohérent avec mcp_endpoint()" + ); + // project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c. + assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string()); + assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex"); + assert!(!rt.project_id.contains('-'), "pas de tirets"); + // requester = l'id de la cible relancée. + assert_eq!(rt.requester, agent_id.to_string()); + // exe non vide. + assert!(!rt.exe.is_empty(), "exe renseigné"); + } +} diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs new file mode 100644 index 0000000..fe0b17b --- /dev/null +++ b/crates/backend/src/openai_tools.rs @@ -0,0 +1,149 @@ +//! ToolInvoker for the OpenAI-compatible adapter. +//! +//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que +//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même +//! `OrchestratorService::dispatch`. + +use std::sync::{Arc, Mutex}; + +use application::OrchestratorService; +use async_trait::async_trait; +use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec}; +use serde_json::Value; + +const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; +const REQUESTER_ARG: &str = "__ideaRequester"; + +/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif. +pub struct AppOpenAiToolInvoker { + orchestrator: Arc, + projects: Arc, +} + +/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la +/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime. +#[derive(Default)] +pub struct LateBoundOpenAiToolInvoker { + inner: Mutex>>, +} + +impl LateBoundOpenAiToolInvoker { + /// Construit un proxy vide. + #[must_use] + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + /// Lie l'implémentation réelle. Appelé une fois par la composition root. + pub fn bind(&self, inner: Arc) { + *self.inner.lock().expect("mutex sain") = Some(inner); + } +} + +#[async_trait] +impl ToolInvoker for LateBoundOpenAiToolInvoker { + fn tools(&self) -> Vec { + self.inner + .lock() + .expect("mutex sain") + .as_ref() + .map_or_else(Vec::new, |inner| inner.tools()) + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + let inner = self + .inner + .lock() + .expect("mutex sain") + .clone() + .ok_or_else(|| { + ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) + })?; + inner.call(name, args_json).await + } +} + +impl AppOpenAiToolInvoker { + /// Construit l'invoker depuis le service orchestrateur et le store projet. + #[must_use] + pub fn new(orchestrator: Arc, projects: Arc) -> Self { + Self { + orchestrator, + projects, + } + } +} + +#[async_trait] +impl ToolInvoker for AppOpenAiToolInvoker { + fn tools(&self) -> Vec { + infrastructure::orchestrator::mcp::catalogue() + .into_iter() + .map(|tool| ToolSpec { + name: tool.name.to_owned(), + description: tool.description.to_owned(), + input_schema: tool.input_schema, + }) + .collect() + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + let value: Value = serde_json::from_str(args_json) + .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; + let args = value.as_object().ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "les arguments d'outil doivent être un objet JSON".to_owned(), + ) + })?; + let project_root = args + .get(PROJECT_ROOT_ARG) + .and_then(Value::as_str) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "contexte projet interne absent pour l'outil".to_owned(), + ) + })?; + let requester = args + .get(REQUESTER_ARG) + .and_then(Value::as_str) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "identité requester interne absente pour l'outil".to_owned(), + ) + })?; + let project = self + .projects + .list_projects() + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))? + .into_iter() + .find(|project| project.root.as_str() == project_root) + .ok_or_else(|| { + ToolInvocationError::Execution(format!( + "projet introuvable pour root `{project_root}`" + )) + })?; + let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester) + .map_err(|e| match e { + infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { + ToolInvocationError::NotFound(tool) + } + infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => { + ToolInvocationError::InvalidArguments(format!( + "arguments invalides pour `{tool}`" + )) + } + infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => { + ToolInvocationError::InvalidArguments(err.to_string()) + } + })?; + let outcome = self + .orchestrator + .dispatch(&project, command) + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))?; + Ok(outcome.reply.unwrap_or(outcome.detail)) + } +}