Lot B2 du chantier server/client mode : le cœur backend émet désormais ses flux via une abstraction de sink agnostique, sans dépendre directement de tauri::ipc::Channel, préalable au futur serveur web + PTY WebSocket. - crates/backend : abstraction de sink (stream.rs) câblée dans lib.rs. - crates/app-tauri : implémentation Tauri du sink (stream.rs) et adaptation des surfaces lib.rs, pty.rs, chat.rs. - Nettoyage clippy des 2 warnings B2. Validé : cargo check --workspace vert, tests backend/app-tauri verts, cœur agnostique Tauri, clippy B2 propre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6399 lines
261 KiB
Rust
6399 lines
261 KiB
Rust
//! 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<dyn Port>` 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;
|
|
pub mod stream;
|
|
|
|
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<Output = ()> + 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<Mutex<Option<Arc<dyn TicketToolProvider>>>>,
|
|
}
|
|
|
|
impl LateBoundTicketToolProvider {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn bind(&self, inner: Arc<dyn TicketToolProvider>) {
|
|
*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<Value, infrastructure::TicketToolError> {
|
|
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** (`<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<Arc<RecordTurn>> {
|
|
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<Arc<dyn domain::ConversationArchive>> {
|
|
Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationArchive>)
|
|
}
|
|
}
|
|
|
|
/// 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**
|
|
/// (`<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<Arc<dyn domain::HandoffStore>> {
|
|
Some(Arc::new(FsHandoffStore::new(root)) as Arc<dyn domain::HandoffStore>)
|
|
}
|
|
}
|
|
|
|
/// 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**
|
|
/// (`<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<Arc<dyn domain::ConversationLog>> {
|
|
Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationLog>)
|
|
}
|
|
}
|
|
|
|
/// 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**
|
|
/// (`<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<dyn Clock>,
|
|
}
|
|
|
|
impl LiveStateProvider for AppLiveStateProvider {
|
|
fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option<Arc<UpdateLiveState>> {
|
|
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** (`<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<dyn Clock>,
|
|
}
|
|
|
|
impl AppLiveStateLeanProvider {
|
|
fn getter(&self, root: &domain::project::ProjectPath) -> Arc<GetLiveStateLean> {
|
|
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<Arc<GetLiveStateLean>> {
|
|
Some(self.getter(root))
|
|
}
|
|
}
|
|
|
|
impl LiveStateReadProvider for AppLiveStateLeanProvider {
|
|
fn live_state_lean_for(
|
|
&self,
|
|
root: &domain::project::ProjectPath,
|
|
) -> Option<Arc<GetLiveStateLean>> {
|
|
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
|
|
/// `<root>/.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<dyn ProjectStore>,
|
|
registry: Arc<LiveSessions>,
|
|
clock: Arc<dyn Clock>,
|
|
}
|
|
|
|
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<dyn LiveAgentRegistry>,
|
|
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 `<root>/.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<dyn ProjectStore>,
|
|
stores: Mutex<HashMap<ProjectId, Arc<FsBackgroundTaskStore>>>,
|
|
task_projects: Mutex<HashMap<TaskId, ProjectId>>,
|
|
}
|
|
|
|
impl AppBackgroundTaskStore {
|
|
fn new(projects: Arc<dyn ProjectStore>) -> Self {
|
|
Self {
|
|
projects,
|
|
stores: Mutex::new(HashMap::new()),
|
|
task_projects: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
async fn store_for_project(
|
|
&self,
|
|
project_id: ProjectId,
|
|
) -> Result<Arc<FsBackgroundTaskStore>, 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<Option<ProjectId>, 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<Vec<BackgroundTask>, 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<Option<BackgroundTask>, 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<Vec<BackgroundTask>, 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<Vec<BackgroundTask>, 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<dyn ProjectStore>,
|
|
store: Arc<AppBackgroundTaskStore>,
|
|
clock: Arc<dyn Clock>,
|
|
ready: tokio::sync::mpsc::UnboundedSender<BackgroundTaskReadyToDeliver>,
|
|
}
|
|
|
|
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<BackgroundTaskReadyToDeliver>,
|
|
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<dyn AgentWakePort>,
|
|
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<LaunchAgent>,
|
|
structured_sessions: Arc<StructuredSessions>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl WakeSessionProvider for AppWakeSessionProvider {
|
|
async fn session_for_wake(
|
|
&self,
|
|
project: &Project,
|
|
agent: AgentId,
|
|
) -> Result<Arc<dyn AgentSession>, 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**
|
|
/// (`<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<Arc<dyn domain::ProviderSessionStore>> {
|
|
Some(Arc::new(FsProviderSessionStore::new(root)) as Arc<dyn domain::ProviderSessionStore>)
|
|
}
|
|
}
|
|
|
|
/// 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<Mutex<HashMap<AgentId, ResumeContext>>>;
|
|
|
|
/// 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<LaunchAgent>` que la commande `launch_agent` (relance/réattache).
|
|
launch_agent: Arc<LaunchAgent>,
|
|
/// Portail d'entrée unique : injecte le prompt de reprise comme premier tour.
|
|
input_mediator: Arc<dyn domain::input::InputMediator>,
|
|
/// 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<String>,
|
|
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<dyn Port>` held inside the use
|
|
/// cases.
|
|
pub struct BackendCore {
|
|
/// Trivial health use case validating the end-to-end wiring.
|
|
pub health: Arc<HealthUseCase>,
|
|
/// Create a project (init `.ideai/`, register it).
|
|
pub create_project: Arc<CreateProject>,
|
|
/// Open a project (load meta + manifest).
|
|
pub open_project: Arc<OpenProject>,
|
|
/// Close a project (persist state).
|
|
pub close_project: Arc<CloseProject>,
|
|
/// Close a tab.
|
|
pub close_tab: Arc<CloseTab>,
|
|
/// List known projects.
|
|
pub list_projects: Arc<ListProjects>,
|
|
/// Read `.ideai/CONTEXT.md`.
|
|
pub read_project_context: Arc<ReadProjectContext>,
|
|
/// Overwrite `.ideai/CONTEXT.md`.
|
|
pub update_project_context: Arc<UpdateProjectContext>,
|
|
/// Open a terminal (spawn PTY, register session).
|
|
pub open_terminal: Arc<OpenTerminal>,
|
|
/// Write keystrokes to a terminal.
|
|
pub write_terminal: Arc<WriteToTerminal>,
|
|
/// Resize a terminal.
|
|
pub resize_terminal: Arc<ResizeTerminal>,
|
|
/// Close a terminal (kill PTY).
|
|
pub close_terminal: Arc<CloseTerminal>,
|
|
/// Load a project's persisted layout tree.
|
|
pub load_layout: Arc<LoadLayout>,
|
|
/// Mutate + persist a project's layout tree.
|
|
pub mutate_layout: Arc<MutateLayout>,
|
|
/// List all named layouts for a project (#4).
|
|
pub list_layouts: Arc<ListLayouts>,
|
|
/// Create a new named layout (#4).
|
|
pub create_layout: Arc<CreateLayout>,
|
|
/// Rename a named layout (#4).
|
|
pub rename_layout: Arc<RenameLayout>,
|
|
/// Delete a named layout (#4).
|
|
pub delete_layout: Arc<DeleteLayout>,
|
|
/// Set the active named layout (#4).
|
|
pub set_active_layout: Arc<SetActiveLayout>,
|
|
/// Freeze `agent_was_running` on every agent leaf before a PTY kill (T5).
|
|
pub snapshot_running_agents: Arc<SnapshotRunningAgents>,
|
|
/// 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<ReconcileLayouts>,
|
|
/// 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<AppReconcileLiveState>,
|
|
/// 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<AppReconcileBackgroundTasks>,
|
|
/// Detect which candidate profiles' CLIs are installed (first-run).
|
|
pub detect_profiles: Arc<DetectProfiles>,
|
|
/// List configured profiles.
|
|
pub list_profiles: Arc<ListProfiles>,
|
|
/// Save (upsert) a profile.
|
|
pub save_profile: Arc<SaveProfile>,
|
|
/// Create a new OpenCode profile instance from the canonical seed.
|
|
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
|
/// Delete a profile.
|
|
pub delete_profile: Arc<DeleteProfile>,
|
|
/// Persist the batch of chosen profiles (closes the first run).
|
|
pub configure_profiles: Arc<ConfigureProfiles>,
|
|
/// Expose the pre-filled reference catalogue.
|
|
pub reference_profiles: Arc<ReferenceProfiles>,
|
|
/// Whether the first-run wizard should show + the reference catalogue.
|
|
pub first_run_state: Arc<FirstRunState>,
|
|
/// Ensures local llama.cpp model servers for OpenCode profiles.
|
|
pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
|
|
/// Lists local model server configurations.
|
|
pub list_model_servers: Arc<ListModelServers>,
|
|
/// Saves local model server configurations.
|
|
pub save_model_server: Arc<SaveModelServer>,
|
|
/// Deletes local model server configurations when unused.
|
|
pub delete_model_server: Arc<DeleteModelServer>,
|
|
/// 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<dyn PtyPort>,
|
|
/// Active-terminal registry shared by the terminal use cases.
|
|
pub terminal_sessions: Arc<TerminalSessions>,
|
|
/// The domain event bus (also handed to the event relay).
|
|
pub event_bus: Arc<TokioBroadcastEventBus>,
|
|
/// Create an issue-backed public ticket.
|
|
pub create_issue: Arc<CreateIssue>,
|
|
/// Read an issue-backed public ticket.
|
|
pub read_issue: Arc<ReadIssue>,
|
|
/// Delete an issue-backed public ticket.
|
|
pub delete_issue: Arc<DeleteIssue>,
|
|
/// List issue-backed public tickets.
|
|
pub list_issues: Arc<ListIssues>,
|
|
/// Update an issue-backed public ticket.
|
|
pub update_issue: Arc<UpdateIssue>,
|
|
/// Read a ticket carnet.
|
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
|
/// Update a ticket carnet.
|
|
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
|
|
/// Link two public tickets.
|
|
pub link_issues: Arc<LinkIssues>,
|
|
/// Unlink public tickets.
|
|
pub unlink_issues: Arc<UnlinkIssues>,
|
|
/// Assign or unassign an agent on a public ticket.
|
|
pub assign_issue_agent: Arc<AssignIssueAgent>,
|
|
/// Create a sprint.
|
|
pub create_sprint: Arc<CreateSprint>,
|
|
/// List sprints.
|
|
pub list_sprints: Arc<ListSprints>,
|
|
/// Rename a sprint.
|
|
pub rename_sprint: Arc<RenameSprint>,
|
|
/// Reorder sprints.
|
|
pub reorder_sprints: Arc<ReorderSprints>,
|
|
/// Delete a sprint.
|
|
pub delete_sprint: Arc<DeleteSprint>,
|
|
/// Assign a ticket to a sprint.
|
|
pub assign_ticket_to_sprint: Arc<AssignTicketToSprint>,
|
|
/// Unassign a ticket from its sprint.
|
|
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
|
|
/// MCP provider for the public `idea_ticket_*` tools.
|
|
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
|
|
/// Open an ephemeral AI assistant chat bound to one ticket.
|
|
pub open_ticket_assistant: Arc<OpenTicketAssistant>,
|
|
/// Close an ephemeral AI assistant chat bound to one ticket.
|
|
pub close_ticket_assistant: Arc<CloseTicketAssistant>,
|
|
/// Shared MCP tool policy registry used by ticket assistant sessions.
|
|
pub tool_policy_registry: Arc<ToolPolicyRegistry>,
|
|
/// 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<StructuredSessions>,
|
|
// --- Agents (L6) ---
|
|
/// Create a project agent from scratch.
|
|
pub create_agent: Arc<CreateAgentFromScratch>,
|
|
/// List a project's agents.
|
|
pub list_agents: Arc<ListAgents>,
|
|
/// Read an agent's Markdown context.
|
|
pub read_agent_context: Arc<ReadAgentContext>,
|
|
/// Overwrite an agent's Markdown context.
|
|
pub update_agent_context: Arc<UpdateAgentContext>,
|
|
/// Delete an agent from the manifest.
|
|
pub delete_agent: Arc<DeleteAgent>,
|
|
/// Launch an agent (spawn PTY, apply injection strategy).
|
|
pub launch_agent: Arc<LaunchAgent>,
|
|
/// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1).
|
|
pub change_agent_profile: Arc<ChangeAgentProfile>,
|
|
/// Read-only inventory of a project's resumable agent cells, for the reopen
|
|
/// panel (§15.2).
|
|
pub list_resumable_agents: Arc<ListResumableAgents>,
|
|
/// Read-only live/busy state for the project's manifest agents.
|
|
pub get_project_work_state: Arc<GetProjectWorkState>,
|
|
/// Human paginated read of a conversation's full transcript (lot LS6).
|
|
pub read_conversation_page: Arc<ReadConversationPage>,
|
|
/// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
|
|
pub rotate_conversation_log: Arc<RotateConversationLog>,
|
|
/// Rebinds an already-running agent's live session to a visible cell (Lot D).
|
|
pub attach_live_agent: Arc<AttachLiveAgent>,
|
|
/// Tears down an already-running agent's live session by agent id (Lot D).
|
|
pub stop_live_agent: Arc<StopLiveAgent>,
|
|
/// 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<InspectConversation>,
|
|
/// Project registry — used by agent commands to resolve a `Project` from an id.
|
|
pub project_store: Arc<dyn ProjectStore>,
|
|
/// Read the project permission document.
|
|
pub get_project_permissions: Arc<GetProjectPermissions>,
|
|
/// Update project-level default permissions.
|
|
pub update_project_permissions: Arc<UpdateProjectPermissions>,
|
|
/// Update one agent permission override.
|
|
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
|
/// Resolve effective permissions for one agent.
|
|
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
|
// --- Windows (L10) ---
|
|
/// Detach a tab into a new OS window (persists the workspace topology).
|
|
pub move_tab: Arc<MoveTabToNewWindow>,
|
|
/// Persist open desktop window state on main-window shutdown.
|
|
pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
|
|
/// Load restorable desktop window state on startup.
|
|
pub restore_open_windows: Arc<RestoreOpenWindows>,
|
|
// --- Background tasks (B8) ---
|
|
/// Spawn a command-backed first-class background task.
|
|
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
|
|
/// Cancel a running background task.
|
|
pub cancel_background_task: Arc<CancelBackgroundTask>,
|
|
/// Retry a terminal command task under a fresh task id.
|
|
pub retry_background_task: Arc<RetryBackgroundTask>,
|
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
|
// --- Templates & sync (L7) ---
|
|
/// Create a template in the global store.
|
|
pub create_template: Arc<CreateTemplate>,
|
|
/// Update a template's content (bumps version).
|
|
pub update_template: Arc<UpdateTemplate>,
|
|
/// List all templates in the global store.
|
|
pub list_templates: Arc<ListTemplates>,
|
|
/// Delete a template from the global store.
|
|
pub delete_template: Arc<DeleteTemplate>,
|
|
/// Create an agent from a template.
|
|
pub create_agent_from_template: Arc<CreateAgentFromTemplate>,
|
|
/// Detect which synchronized agents are behind their template.
|
|
pub detect_agent_drift: Arc<DetectAgentDrift>,
|
|
/// Apply a template update to a synchronized agent.
|
|
pub sync_agent_with_template: Arc<SyncAgentWithTemplate>,
|
|
// --- Git (L8) ---
|
|
/// Report the working-tree status of a repository.
|
|
pub git_status: Arc<GitStatus>,
|
|
/// Stage a path.
|
|
pub git_stage: Arc<GitStage>,
|
|
/// Unstage a path.
|
|
pub git_unstage: Arc<GitUnstage>,
|
|
/// Create a commit.
|
|
pub git_commit: Arc<GitCommit>,
|
|
/// List branches.
|
|
pub git_branches: Arc<GitBranches>,
|
|
/// Check out a branch.
|
|
pub git_checkout: Arc<GitCheckout>,
|
|
/// Return the recent commit log.
|
|
pub git_log: Arc<GitLog>,
|
|
/// Initialise a repository.
|
|
pub git_init: Arc<GitInit>,
|
|
/// Return the commit graph for all local branches.
|
|
pub git_graph: Arc<GitGraph>,
|
|
// --- Skills (L12) ---
|
|
/// Create a skill in a scope's store.
|
|
pub create_skill: Arc<CreateSkill>,
|
|
/// Update a skill's content.
|
|
pub update_skill: Arc<UpdateSkill>,
|
|
/// List skills in a scope.
|
|
pub list_skills: Arc<ListSkills>,
|
|
/// Delete a skill from its scope's store.
|
|
pub delete_skill: Arc<DeleteSkill>,
|
|
/// Assign a skill to an agent (records a `SkillRef`).
|
|
pub assign_skill: Arc<AssignSkillToAgent>,
|
|
/// Unassign a skill from an agent.
|
|
pub unassign_skill: Arc<UnassignSkillFromAgent>,
|
|
|
|
// --- Memory (LOT A — §14.5.1) ---
|
|
/// Create a memory note in the project's store.
|
|
pub create_memory: Arc<CreateMemory>,
|
|
/// Replace an existing memory note.
|
|
pub update_memory: Arc<UpdateMemory>,
|
|
/// List the project's memory notes.
|
|
pub list_memories: Arc<ListMemories>,
|
|
/// Read one memory note by slug.
|
|
pub get_memory: Arc<GetMemory>,
|
|
/// Delete a memory note.
|
|
pub delete_memory: Arc<DeleteMemory>,
|
|
/// Read the structured `MEMORY.md` index.
|
|
pub read_memory_index: Arc<ReadMemoryIndex>,
|
|
/// Resolve a note's outgoing `[[slug]]` links.
|
|
pub resolve_memory_links: Arc<ResolveMemoryLinks>,
|
|
/// Recall the most relevant memory entries for a query within a budget
|
|
/// (LOT B — §14.5.2).
|
|
pub recall_memory: Arc<RecallMemory>,
|
|
// --- Embedder config (LOT C2 — §14.5.3) ---
|
|
/// List the configured embedder profiles (`embedder.json`).
|
|
pub list_embedder_profiles: Arc<ListEmbedderProfiles>,
|
|
/// Save (upsert, validating) an embedder profile.
|
|
pub save_embedder_profile: Arc<SaveEmbedderProfile>,
|
|
/// Delete an embedder profile by id.
|
|
pub delete_embedder_profile: Arc<DeleteEmbedderProfile>,
|
|
/// Describe the embedding engines available to the configuration UI (catalogue
|
|
/// + detected local environment + compiled-in capabilities).
|
|
pub describe_embedder_engines: Arc<DescribeEmbedderEngines>,
|
|
// --- Embedder suggestion (LOT C3 — §14.5.5) ---
|
|
/// Persist the user's response to the embedder suggestion (`later`/`never`).
|
|
pub dismiss_embedder_suggestion: Arc<DismissEmbedderSuggestion>,
|
|
// --- Orchestrator (§14.3) ---
|
|
/// Dispatches validated orchestrator requests to the agent/skill use cases.
|
|
/// Shared by every per-project filesystem watcher.
|
|
pub orchestrator_service: Arc<OrchestratorService>,
|
|
/// 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<HashMap<ProjectId, OrchestratorWatchHandle>>,
|
|
/// 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<HashMap<ProjectId, McpServerHandle>>,
|
|
/// 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<SessionLimitService>,
|
|
/// 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<dyn domain::ports::TurnWatcher>,
|
|
/// 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<dyn domain::input::InputMediator>,
|
|
/// 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<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>,
|
|
/// 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<dyn FileSystem>,
|
|
/// Répertoire home (`$HOME`) servant à dériver `<home>/.claude/projects/<encoded-run-dir>`
|
|
/// 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<LateBoundTicketToolProvider>,
|
|
}
|
|
|
|
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<dyn FileSystem>,
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
));
|
|
let window_state_store = Arc::new(FsWindowStateStore::new(
|
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
));
|
|
|
|
// Port-typed handles for injection.
|
|
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
|
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
|
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
|
|
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
|
let health = Arc::new(HealthUseCase::new(
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
|
|
let create_project = Arc::new(CreateProject::new(
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&fs_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn PtyPort>;
|
|
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<dyn ToolInvoker>;
|
|
let session_factory = Arc::new(
|
|
StructuredSessionFactory::new()
|
|
.with_sandbox_enforcer(infrastructure::default_enforcer())
|
|
.with_tool_invoker(openai_tool_invoker_port),
|
|
) as Arc<dyn AgentSessionFactory>;
|
|
|
|
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<dyn IdGenerator>,
|
|
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<dyn LiveAgentRegistry>,
|
|
));
|
|
|
|
// 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<dyn ProcessSpawner>;
|
|
let runtime = Arc::new(CliAgentRuntime::new(Arc::clone(&spawner_port)));
|
|
let runtime_port = Arc::clone(&runtime) as Arc<dyn AgentRuntime>;
|
|
|
|
let profile_store = Arc::new(FsProfileStore::new(
|
|
Arc::clone(&fs_port),
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
));
|
|
let profile_store_port: Arc<dyn ProfileStore> =
|
|
Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
|
|
|
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<dyn IdGenerator>,
|
|
));
|
|
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<dyn domain::ports::ModelServerRegistry>,
|
|
Arc::new(HttpOpenAiCompatibleProbe::default())
|
|
as Arc<dyn domain::ports::ModelServerProbe>,
|
|
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
|
|
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
|
Arc::clone(&fs_port),
|
|
Arc::clone(&events_port),
|
|
)
|
|
.with_model_artifact_downloader(
|
|
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
|
|
),
|
|
);
|
|
let model_server_registry_port =
|
|
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
|
|
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<dyn AgentContextStore>;
|
|
|
|
// --- Public tickets (Issue domain) ---
|
|
// One stateless FS store/allocator serves every project; the project root is
|
|
// passed on each port call and resolves to `<root>/.ideai/tickets/`.
|
|
let issue_store = Arc::new(FsIssueStore::new());
|
|
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
|
|
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
|
|
let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
|
|
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<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn Clock>,
|
|
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<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let link_issues = Arc::new(LinkIssues::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let unlink_issues = Arc::new(UnlinkIssues::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let sprint_store = Arc::new(FsSprintStore::new());
|
|
let sprint_store_port = Arc::clone(&sprint_store) as Arc<dyn SprintStore>;
|
|
let create_sprint = Arc::new(CreateSprint::new(
|
|
Arc::clone(&sprint_store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let reorder_sprints = Arc::new(ReorderSprints::new(
|
|
Arc::clone(&sprint_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn Clock>,
|
|
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<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let unassign_ticket_from_sprint = Arc::new(UnassignTicketFromSprint::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new());
|
|
let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
|
|
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
|
|
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
|
|
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
|
|
Arc::clone(&fs_port),
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
)) as Arc<dyn AssistantContextProvider>;
|
|
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<dyn StructuredSessionEnvironmentPreparer>;
|
|
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<dyn PermissionStore>;
|
|
|
|
// --- 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<dyn SkillStore>;
|
|
|
|
// 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<dyn MemoryStore>;
|
|
// 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<dyn EmbedderProfileStore>;
|
|
// `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<dyn EmbedderEnvInspector>;
|
|
// 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<OnnxModelView> = 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<dyn EmbedderPromptStore>;
|
|
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<dyn IdGenerator>,
|
|
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<dyn domain::PermissionProjector>)
|
|
.with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>),
|
|
);
|
|
|
|
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<dyn IdGenerator>,
|
|
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 (`<root>/.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<dyn application::HandoffProvider>
|
|
)
|
|
// Resumable moteur par provider (lot P8b) : après un lancement structuré
|
|
// exposant un id de session moteur, rangé sous la clé de paire dans
|
|
// `<root>/.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<dyn application::ProviderSessionProvider>)
|
|
// 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<dyn Clock>,
|
|
}) as Arc<dyn LiveStateLeanProvider>)
|
|
.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<dyn IdGenerator>,
|
|
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<dyn application::HandoffProvider>
|
|
)
|
|
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
|
|
as Arc<dyn application::ProviderSessionProvider>)
|
|
.with_permission_projectors(Arc::clone(&permission_projectors))
|
|
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
|
}) as Arc<dyn LiveStateLeanProvider>)
|
|
.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 `<home>/.claude/projects/<cwd>/<id>.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<Arc<dyn domain::ports::SessionInspector>> = 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 `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
|
|
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
|
|
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> =
|
|
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<dyn TemplateStore>;
|
|
|
|
let create_template = Arc::new(CreateTemplate::new(
|
|
Arc::clone(&template_store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
));
|
|
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<dyn IdGenerator>,
|
|
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<dyn GitPort>;
|
|
|
|
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<dyn IdGenerator>,
|
|
));
|
|
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<dyn domain::mailbox::AgentMailbox>;
|
|
// 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<dyn domain::mailbox::AgentQueueSnapshot>;
|
|
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<dyn domain::input::InputMediator>;
|
|
let background_tasks = Arc::new(AppBackgroundTaskStore::new(Arc::clone(&store_port)));
|
|
let background_tasks_port = Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>;
|
|
let (background_ready_tx, mut background_ready_rx) =
|
|
tokio::sync::mpsc::unbounded_channel::<BackgroundTaskReadyToDeliver>();
|
|
|
|
// --- 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<dyn Clock>,
|
|
));
|
|
let background_runner_port =
|
|
Arc::clone(&background_runner) as Arc<dyn BackgroundTaskRunner>;
|
|
{
|
|
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<dyn Clock>,
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
));
|
|
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<dyn BackgroundCommandArchive>,
|
|
Arc::clone(&spawn_background_command),
|
|
));
|
|
let background_wake = Arc::new(AgentWakeService::new(
|
|
Arc::clone(&mediated_inbox) as Arc<dyn AgentInbox>,
|
|
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<dyn AgentWakePort>;
|
|
{
|
|
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<dyn Clock>;
|
|
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<dyn Clock>,
|
|
});
|
|
let reconcile_background_tasks = Arc::new(AppReconcileBackgroundTasks {
|
|
projects: Arc::clone(&store_port),
|
|
store: Arc::clone(&background_tasks),
|
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn application::HandoffProvider>,
|
|
Arc::new(AppConversationLogProvider)
|
|
as Arc<dyn application::ConversationLogProvider>,
|
|
)
|
|
.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<dyn application::ConversationArchiveProvider>;
|
|
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<dyn application::HandoffProvider>,
|
|
));
|
|
// 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::<ScheduledTask>();
|
|
let scheduler = Arc::new(TokioScheduler::new(
|
|
resume_tx,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
)) as Arc<dyn Scheduler>;
|
|
let resumer = Arc::new(AppAgentResumer {
|
|
launch_agent: Arc::clone(&launch_agent),
|
|
input_mediator: Arc::clone(&input_mediator),
|
|
contexts: Arc::clone(&resume_contexts),
|
|
}) as Arc<dyn AgentResumer>;
|
|
let session_limit_service = Arc::new(SessionLimitService::new(
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
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<dyn domain::conversation::ConversationRegistry>;
|
|
// Garde FileGuard partagé (cadrage C7) : UN SEUL `RwFileGuard` casté une fois en
|
|
// `Arc<dyn FileGuard>` 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<dyn domain::fileguard::FileGuard>;
|
|
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<dyn Clock>,
|
|
)),
|
|
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<dyn application::McpRuntimeProvider>
|
|
)
|
|
// Persistance conversationnelle best-effort (lot P6b) : Prompt + Response de
|
|
// chaque paire déléguée écrits dans `<root>/.ideai/conversations/<conv>` 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<dyn RecordTurnProvider>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
)
|
|
// 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 (`<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<dyn Clock>,
|
|
}) as Arc<dyn LiveStateProvider>)
|
|
// 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<dyn Clock>,
|
|
}) as Arc<dyn LiveStateReadProvider>)
|
|
// 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 = `<root>/.ideai/
|
|
// run/<agent_id>`, encodé par Claude sous `<home>/.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<Box<dyn std::future::Future<Output = Option<u64>> + 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::<u32>().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<dyn Clock>,
|
|
)
|
|
// 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<dyn ToolInvoker>);
|
|
|
|
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<dyn IdGenerator>,
|
|
));
|
|
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<dyn Fn(DomainEvent) + Send + Sync> =
|
|
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<dyn Fn(DomainEvent) + Send + Sync> =
|
|
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<dyn Fn(&str) + Send + Sync> = 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<ProjectId> {
|
|
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 (`<root>/.ideai/run/<agent>`) 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<String>,
|
|
) {
|
|
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 `<home>/.claude/projects/<encoded-run-dir>/` 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<String>, 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<Value> {
|
|
let mut doc = match serde_json::from_str::<Value>(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<Value> {
|
|
let mut doc = match serde_json::from_str::<Value>(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<Item = &'a str>,
|
|
) -> Vec<Value> {
|
|
let mut seen = HashSet::<String>::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<LocalSocketListener> {
|
|
// 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::<GenericFilePath>().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<S>(server: Arc<McpServer>, 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::<serde_json::Value>(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<LocalSocketListener>,
|
|
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<dyn MemoryRecall>`. 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<dyn FileSystem>,
|
|
store: Arc<dyn MemoryStore>,
|
|
profile: &EmbedderProfile,
|
|
onnx_cache_dir: &std::path::Path,
|
|
) -> Arc<dyn MemoryRecall> {
|
|
let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc<dyn MemoryRecall>;
|
|
match embedder_from_profile(profile, onnx_cache_dir) {
|
|
None => naive,
|
|
Some(embedder) => {
|
|
let embedder: Arc<dyn Embedder> = Arc::from(embedder);
|
|
let vector: Arc<dyn MemoryRecall> = 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<HashMap<ProjectId, Project>>,
|
|
}
|
|
|
|
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<Vec<Project>, StoreError> {
|
|
Ok(self.projects.lock().unwrap().values().cloned().collect())
|
|
}
|
|
|
|
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
|
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<domain::Workspace, StoreError> {
|
|
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<dyn ProjectStore>
|
|
));
|
|
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<dyn ProjectStore>,
|
|
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<S>` 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<String, String>,
|
|
}
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
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<String> {
|
|
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<MarkdownDoc, StoreError> {
|
|
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<AgentManifest, StoreError> {
|
|
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<Vec<AgentProfile>>);
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
|
|
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<Vec<Skill>, StoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
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<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
struct FakeRuntime;
|
|
#[async_trait]
|
|
impl AgentRuntime for FakeRuntime {
|
|
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
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<Vec<u8>, 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<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, 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<PtyHandle, PtyError> {
|
|
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<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn wait(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
fn try_wait(&self, _h: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(Some(ExitStatus { code: Some(0) }))
|
|
}
|
|
async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
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<u128>);
|
|
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<dyn Fn(DomainEvent) + Send + Sync>,
|
|
Arc<Mutex<Vec<DomainEvent>>>,
|
|
) {
|
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = captured.clone();
|
|
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
|
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<OrchestratorService> {
|
|
// 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<dyn ProfileStore>,
|
|
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<dyn SkillStore>,
|
|
Arc::new(SeqIds(Mutex::new(1))),
|
|
));
|
|
Arc::new(OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
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<R>(client: &mut R) -> Option<Value>
|
|
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<McpServer>,
|
|
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<HashMap<String, String>>,
|
|
}
|
|
#[async_trait]
|
|
impl MemoryStore for FakeMemory {
|
|
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
|
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<Vec<MemoryIndexEntry>, MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn resolve_links(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_slug: &MemorySlug,
|
|
) -> Result<Vec<MemoryLink>, 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<OrchestratorService>, Arc<FakeMemory>) {
|
|
let memory = Arc::new(FakeMemory::default());
|
|
let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
|
|
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<dyn MemoryStore>,
|
|
)),
|
|
write_memory: Arc::new(WriteMemory::new(
|
|
Arc::clone(&file_guard),
|
|
Arc::clone(&memory) as Arc<dyn MemoryStore>,
|
|
)),
|
|
});
|
|
// 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<domain::live_state::LiveState>,
|
|
}
|
|
#[async_trait]
|
|
impl domain::ports::LiveStateStore for InMemLiveStore {
|
|
async fn load(&self) -> Result<domain::live_state::LiveState, StoreError> {
|
|
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<application::UpdateLiveState>,
|
|
}
|
|
impl application::LiveStateProvider for WsWriteProvider {
|
|
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<application::UpdateLiveState>> {
|
|
Some(Arc::clone(&self.update))
|
|
}
|
|
}
|
|
struct WsReadProvider {
|
|
getter: Arc<application::GetLiveStateLean>,
|
|
}
|
|
impl application::LiveStateReadProvider for WsReadProvider {
|
|
fn live_state_lean_for(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
) -> Option<Arc<application::GetLiveStateLean>> {
|
|
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<OrchestratorService> {
|
|
let store = Arc::new(InMemLiveStore::default()) as Arc<dyn domain::ports::LiveStateStore>;
|
|
let clock = Arc::new(FixedClock) as Arc<dyn Clock>;
|
|
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<dyn application::LiveStateProvider>)
|
|
.with_live_state_read(read as Arc<dyn application::LiveStateReadProvider>);
|
|
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<HashMap<String, Vec<u8>>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileSystem for MemFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
|
|
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<Vec<DirEntry>, 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<dyn FileSystem>, Arc<dyn MemoryStore>) {
|
|
let fs: Arc<dyn FileSystem> = 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<dyn MemoryStore> = 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<String, String>,
|
|
}
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
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<String> {
|
|
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<MarkdownDoc, StoreError> {
|
|
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<AgentManifest, StoreError> {
|
|
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<Vec<AgentProfile>>);
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
|
|
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<Vec<Skill>, StoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
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<Vec<domain::MemoryIndexEntry>, 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<String> {
|
|
Some(self.id.to_string())
|
|
}
|
|
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
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<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
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<Vec<u8>, 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<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, 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<PtyHandle, PtyError> {
|
|
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<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn wait(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
fn try_wait(&self, _h: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(Some(ExitStatus { code: Some(0) }))
|
|
}
|
|
async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
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<u128>);
|
|
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<dyn Fn(DomainEvent) + Send + Sync>,
|
|
Arc<Mutex<Vec<DomainEvent>>>,
|
|
) {
|
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = captured.clone();
|
|
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
|
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<OrchestratorService>,
|
|
Arc<TerminalSessions>,
|
|
Arc<InMemoryMailbox>,
|
|
) {
|
|
// 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<dyn ProfileStore>,
|
|
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<dyn SkillStore>,
|
|
Arc::new(SeqIds(Mutex::new(1))),
|
|
));
|
|
let input = Arc::new(MediatedInbox::with_pty(
|
|
Arc::clone(&mailbox),
|
|
Arc::new(SystemMillisClock),
|
|
Arc::new(FakePty) as Arc<dyn PtyPort>,
|
|
)) as Arc<dyn domain::input::InputMediator>;
|
|
let conversations = Arc::new(InMemoryConversationRegistry::new())
|
|
as Arc<dyn domain::conversation::ConversationRegistry>;
|
|
let service = OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
Arc::clone(&sessions),
|
|
)
|
|
.with_input_mediator(
|
|
input,
|
|
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
|
)
|
|
.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<OrchestratorService>,
|
|
Arc<TerminalSessions>,
|
|
Arc<InMemoryMailbox>,
|
|
) {
|
|
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<dyn ProfileStore>,
|
|
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<dyn SkillStore>,
|
|
Arc::new(SeqIds(Mutex::new(1))),
|
|
));
|
|
let input = Arc::new(MediatedInbox::with_pty(
|
|
Arc::clone(&mailbox),
|
|
Arc::new(SystemMillisClock),
|
|
Arc::new(FakePty) as Arc<dyn PtyPort>,
|
|
)) as Arc<dyn domain::input::InputMediator>;
|
|
let conversations = Arc::new(InMemoryConversationRegistry::new())
|
|
as Arc<dyn domain::conversation::ConversationRegistry>;
|
|
let service = OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
Arc::clone(&sessions),
|
|
)
|
|
.with_input_mediator(
|
|
input,
|
|
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
|
)
|
|
.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<OrchestratorService>,
|
|
project: &Project,
|
|
events: Option<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
|
|
) -> (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::<GenericFilePath>()
|
|
.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<R>(reader: &mut R) -> Option<Value>
|
|
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)"
|
|
);
|
|
}
|
|
}
|