Étend le port/usecases window et layout.customPluginLayout pour ouvrir et piloter des fenêtres OS hébergeant un layout plugin, en réutilisant contributes.layouts plutôt qu'un système de fenêtres parallèle. Câble la commande Tauri et l'état app-tauri correspondants. Cargo test -p domain --test window : 5/5 Cargo test -p application --test window_usecases : 7/7 Cargo test -p infrastructure --test window_state_store : 1/1 Cargo test -p app-tauri view_window_tests : 8/8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7045 lines
292 KiB
Rust
7045 lines
292 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::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
|
use application::{
|
|
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
|
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
|
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
|
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
|
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
|
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
|
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
|
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
|
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
|
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
|
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
|
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
|
MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal,
|
|
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
|
PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments,
|
|
PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics,
|
|
PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext,
|
|
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
|
ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory,
|
|
ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
|
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
|
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
|
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
|
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
|
|
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
|
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
|
|
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
|
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
|
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
|
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
|
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
|
UpdateProjectSystemPermissions, 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, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
|
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
|
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
|
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
|
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
|
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
|
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
|
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
|
WakeError, WakeReason, WindowStateStore,
|
|
};
|
|
use domain::profile::{
|
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
|
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
|
};
|
|
use domain::remote::RemoteKind;
|
|
use domain::{
|
|
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, 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,
|
|
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
|
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
|
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
|
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
|
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
|
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
|
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
|
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
|
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
|
SystemClock, SystemMillisClock, TemplateToolProvider, 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 dto;
|
|
pub mod events;
|
|
pub mod mcp_endpoint;
|
|
pub mod openai_tools;
|
|
pub mod stream;
|
|
mod ticket_dto;
|
|
|
|
pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct LateBoundTemplateToolProvider {
|
|
inner: Arc<Mutex<Option<Arc<dyn TemplateToolProvider>>>>,
|
|
}
|
|
|
|
impl LateBoundTemplateToolProvider {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn bind(&self, inner: Arc<dyn TemplateToolProvider>) {
|
|
*self.inner.lock().expect("template tool mutex poisoned") = Some(inner);
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TemplateToolProvider for LateBoundTemplateToolProvider {
|
|
async fn handle_template_tool(
|
|
&self,
|
|
project: &Project,
|
|
requester: &str,
|
|
name: &str,
|
|
arguments: Value,
|
|
) -> Result<Value, infrastructure::TemplateToolError> {
|
|
let inner = self
|
|
.inner
|
|
.lock()
|
|
.expect("template tool mutex poisoned")
|
|
.clone()
|
|
.ok_or_else(|| {
|
|
infrastructure::TemplateToolError::new(
|
|
"UNAVAILABLE",
|
|
"template tool provider not bound".to_owned(),
|
|
)
|
|
})?;
|
|
inner
|
|
.handle_template_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_in_project(project.id, &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_in_project(project.id, &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`] ne porte pas le `Project` complet 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 clé runtime
|
|
/// `(project_id, agent_id)` ; [`AppAgentResumer`] le relit à l'échéance pour
|
|
/// recomposer un lancement complet sans collision entre projets.
|
|
#[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é `(project_id, 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<domain::RuntimeAgentKey, 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,
|
|
project_id: ProjectId,
|
|
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(&domain::RuntimeAgentKey::new(project_id, 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(),
|
|
});
|
|
|
|
let project_id = ctx.project.id;
|
|
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(domain::RuntimeAgentKey::new(project_id, 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>,
|
|
/// Pair a persistent device session.
|
|
pub pair_device: Arc<PairDevice>,
|
|
/// Limits failed pairing attempts.
|
|
pub pair_attempt_limiter: Arc<dyn PairAttemptLimiter>,
|
|
/// Authenticate a persistent device session.
|
|
pub authenticate_session: Arc<AuthenticateSession>,
|
|
/// Throttled update of the authenticated device last-seen timestamp.
|
|
pub touch_device: Arc<TouchDevice>,
|
|
/// List paired devices.
|
|
pub list_devices: Arc<ListDevices>,
|
|
/// Rename a paired device.
|
|
pub rename_device: Arc<RenameDevice>,
|
|
/// Revoke one paired device.
|
|
pub revoke_device: Arc<RevokeDevice>,
|
|
/// Revoke every paired device.
|
|
pub revoke_all_devices: Arc<RevokeAllDevices>,
|
|
/// Paired-device store port, exposed for legacy logout revocation.
|
|
pub device_session_store: Arc<dyn DeviceSessionStore>,
|
|
/// 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>,
|
|
/// Validate/open a plugin layout in a detached OS window.
|
|
pub open_plugin_layout_window: Arc<OpenPluginLayoutWindow>,
|
|
/// 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>,
|
|
/// Save (upsert) an OpenCode profile backed by a cloud provider, sealing its
|
|
/// literal API key into the [`SecretStore`] (ticket #92, lot B3).
|
|
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
|
|
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
|
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
|
/// Static curated Claude model catalogue.
|
|
pub list_claude_models: Arc<ListClaudeModels>,
|
|
/// Static curated Codex model catalogue.
|
|
pub list_codex_models: Arc<ListCodexModels>,
|
|
/// Create a new profile instance from a persisted/reference seed.
|
|
pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
|
|
/// 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>,
|
|
/// Deletes managed local model artifacts without deleting server configs.
|
|
pub delete_model_artifact: Arc<DeleteModelArtifact>,
|
|
/// 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>,
|
|
/// Update public ticket statuses in batch.
|
|
pub bulk_update_issue_status: Arc<BulkUpdateIssueStatus>,
|
|
/// Update public ticket priorities in batch.
|
|
pub bulk_update_issue_priority: Arc<BulkUpdateIssuePriority>,
|
|
/// Delete public tickets in batch.
|
|
pub bulk_delete_issues: Arc<BulkDeleteIssues>,
|
|
/// Read a ticket carnet.
|
|
pub read_issue_carnet: Arc<ReadIssueCarnet>,
|
|
/// Update a ticket carnet.
|
|
pub update_issue_carnet: Arc<UpdateIssueCarnet>,
|
|
/// Attach a local file to a ticket.
|
|
pub add_issue_attachment: Arc<AddIssueAttachment>,
|
|
/// Read one ticket attachment.
|
|
pub read_issue_attachment: Arc<ReadIssueAttachment>,
|
|
/// Mark a ticket attachment as summarized in the carnet.
|
|
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
|
/// 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>,
|
|
/// MCP provider for the public `idea_template_*` tools.
|
|
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
|
|
/// 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>,
|
|
/// Durable per-agent MCP tool permission store.
|
|
pub mcp_tool_permission_store: Arc<dyn McpToolPermissionStore>,
|
|
/// Read durable MCP tool permissions plus canonical catalogue classification.
|
|
pub read_mcp_tool_permissions: Arc<ReadMcpToolPermissions>,
|
|
/// Replace or clear the project-wide MCP tool policy.
|
|
pub update_project_mcp_tool_permissions: Arc<UpdateProjectMcpToolPermissions>,
|
|
/// Replace or clear one agent MCP tool policy override.
|
|
pub update_agent_mcp_tool_permissions: Arc<UpdateAgentMcpToolPermissions>,
|
|
/// 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>,
|
|
/// App-wide work-in-progress guard used before confirmed application exit.
|
|
pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
|
|
/// 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>,
|
|
/// Update one agent effort override.
|
|
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
|
/// Resolve effective permissions for one agent.
|
|
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
|
/// Read the project system permission document.
|
|
pub get_project_system_permissions: Arc<GetProjectSystemPermissions>,
|
|
/// Update project-level default system permissions.
|
|
pub update_project_system_permissions: Arc<UpdateProjectSystemPermissions>,
|
|
/// Update one agent system permission override.
|
|
pub update_agent_system_permissions: Arc<UpdateAgentSystemPermissions>,
|
|
/// Resolve effective system permissions for one agent.
|
|
pub resolve_agent_system_permissions: Arc<ResolveAgentSystemPermissions>,
|
|
// --- 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>,
|
|
/// Concrete command runner retained for infrastructure-only live PTY attach.
|
|
pub background_command_runner: Arc<CommandBackgroundRunner>,
|
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
|
// --- Plugins (#43) ---
|
|
/// Review a local plugin package before install.
|
|
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
|
/// Install a plugin archive.
|
|
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
|
|
/// Install a plugin directory snapshot.
|
|
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
|
|
/// List installed plugins for admin UI.
|
|
pub list_plugins: Arc<ListPlugins>,
|
|
/// Enable or disable a plugin.
|
|
pub set_plugin_enabled: Arc<SetPluginEnabled>,
|
|
/// Uninstall a plugin.
|
|
pub uninstall_plugin: Arc<UninstallPlugin>,
|
|
/// List runtime contributions for UI bootstrap.
|
|
pub list_plugin_runtime_contributions: Arc<ListPluginRuntimeContributions>,
|
|
/// Reconcile external MCP plugin servers.
|
|
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
|
/// Public plugin workspace/file access facade.
|
|
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
|
/// Public plugin structured config document facade.
|
|
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
|
/// Public plugin-owned storage facade.
|
|
pub plugin_storage_access: Arc<PluginStorageAccess>,
|
|
/// Public plugin project-structure query use case.
|
|
pub query_project_structure: Arc<QueryProjectStructure>,
|
|
/// Public plugin command/task facade.
|
|
pub plugin_command_tasks: Arc<PluginCommandTasks>,
|
|
/// Public plugin external-toolchain diagnostic facade.
|
|
pub plugin_toolchain_diagnostics: Arc<PluginToolchainDiagnostics>,
|
|
/// Public plugin event subscription facade.
|
|
pub plugin_event_subscriptions: Arc<PluginEventSubscriptions>,
|
|
/// Package store exposed for the Tauri asset protocol adapter.
|
|
pub plugin_package_store: Arc<FsPluginPackageStore>,
|
|
/// Registry store exposed for the Tauri asset protocol adapter.
|
|
pub plugin_registry_store: Arc<dyn PluginRegistryStore>,
|
|
/// Manifest validator exposed for the Tauri asset protocol adapter.
|
|
pub plugin_manifest_validator: Arc<dyn PluginManifestValidator>,
|
|
// --- Templates & sync (L7) ---
|
|
/// Create a template in the global store.
|
|
pub create_template: Arc<CreateTemplate>,
|
|
/// Read a template in the global store.
|
|
pub read_template: Arc<ReadTemplate>,
|
|
/// 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 `(project_id, 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 clé runtime. (Re)lancer un agent
|
|
/// dans un projet **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<domain::RuntimeAgentKey, 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>,
|
|
/// Late-bound concrete template MCP provider supplied by the driving adapter.
|
|
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
|
|
}
|
|
|
|
/// Backend decision for attaching a UI subscriber to a background task.
|
|
pub struct BackgroundTaskAttachPlan {
|
|
/// Attached task id.
|
|
pub task_id: TaskId,
|
|
/// Bytes to repaint immediately.
|
|
pub scrollback: Vec<u8>,
|
|
/// Live PTY handle to subscribe to. `None` means the task is terminal and
|
|
/// the persisted tail is the whole attach payload.
|
|
pub live_handle: Option<PtyHandle>,
|
|
}
|
|
|
|
fn map_background_task_port_error(err: BackgroundTaskPortError) -> AppError {
|
|
match err {
|
|
BackgroundTaskPortError::NotFound => AppError::NotFound("background task".to_owned()),
|
|
BackgroundTaskPortError::AlreadyExists => {
|
|
AppError::Invalid("background task already exists".to_owned())
|
|
}
|
|
BackgroundTaskPortError::Invalid(msg) => AppError::Invalid(msg),
|
|
BackgroundTaskPortError::Runner(msg) => AppError::Process(msg),
|
|
BackgroundTaskPortError::Store(msg) => AppError::Store(msg),
|
|
}
|
|
}
|
|
|
|
fn background_task_tail_bytes(task: &BackgroundTask) -> Vec<u8> {
|
|
match &task.result {
|
|
Some(BackgroundTaskResult::Success {
|
|
stdout_tail,
|
|
stderr_tail,
|
|
..
|
|
})
|
|
| Some(BackgroundTaskResult::Failure {
|
|
stdout_tail,
|
|
stderr_tail,
|
|
..
|
|
}) => [stdout_tail.as_deref(), stderr_tail.as_deref()]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
.into_bytes(),
|
|
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
impl BackendCore {
|
|
/// Builds an attach plan for a background task output view.
|
|
///
|
|
/// A running task uses the runner's live PTY handle plus PTY scrollback.
|
|
/// A completed task intentionally returns only the persisted output tail and
|
|
/// no handle, so callers do not subscribe to a dead PTY.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::NotFound`] when the task is neither live in the
|
|
/// runner nor terminal in the store, and [`AppError::Process`] /
|
|
/// [`AppError::Store`] for PTY/store failures.
|
|
pub async fn background_task_attach_plan(
|
|
&self,
|
|
task_id: TaskId,
|
|
) -> Result<BackgroundTaskAttachPlan, AppError> {
|
|
if let Some(handle) = self.background_command_runner.pty_handle_for(task_id) {
|
|
let scrollback = self.pty_port.scrollback(&handle).map_err(AppError::from)?;
|
|
return Ok(BackgroundTaskAttachPlan {
|
|
task_id,
|
|
scrollback,
|
|
live_handle: Some(handle),
|
|
});
|
|
}
|
|
|
|
let task = self
|
|
.background_task_store
|
|
.get(task_id)
|
|
.await
|
|
.map_err(map_background_task_port_error)?
|
|
.ok_or_else(|| AppError::NotFound("background task".to_owned()))?;
|
|
if task.is_terminal() {
|
|
return Ok(BackgroundTaskAttachPlan {
|
|
task_id,
|
|
scrollback: background_task_tail_bytes(&task),
|
|
live_handle: None,
|
|
});
|
|
}
|
|
|
|
Err(AppError::NotFound("background task live handle".to_owned()))
|
|
}
|
|
|
|
/// **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(),
|
|
));
|
|
let device_session_store = Arc::new(FsDeviceSessionStore::new(
|
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
));
|
|
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
|
|
Arc::clone(&clock) as Arc<dyn Clock>
|
|
));
|
|
|
|
// 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 device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
|
|
let pair_attempt_limiter_port =
|
|
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
|
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
|
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
|
let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
|
|
let plugin_validator =
|
|
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
|
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
|
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
|
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
|
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
|
|
let plugin_manifest_validator =
|
|
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
|
let plugin_mcp_supervisor_port =
|
|
Arc::clone(&plugin_mcp_supervisor) as Arc<dyn PluginMcpSupervisor>;
|
|
|
|
// --- 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 pair_device = Arc::new(PairDevice::new(
|
|
Arc::clone(&device_session_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
));
|
|
let authenticate_session =
|
|
Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port)));
|
|
let touch_device = Arc::new(TouchDevice::new(
|
|
Arc::clone(&device_session_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
));
|
|
let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port)));
|
|
let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port)));
|
|
let revoke_device = Arc::new(RevokeDevice::new(
|
|
Arc::clone(&device_session_port),
|
|
Arc::clone(&events_port),
|
|
));
|
|
let revoke_all_devices = Arc::new(RevokeAllDevices::new(
|
|
Arc::clone(&device_session_port),
|
|
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),
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
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 environment_reader = Arc::new(LocalEnvironmentReader::new());
|
|
let environment_reader_port = Arc::clone(&environment_reader) as Arc<dyn EnvironmentReader>;
|
|
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>;
|
|
|
|
// Secret store (ticket #92, lot B2/B3): same app-data dir as `profiles.json`,
|
|
// but its own encrypted-at-rest files (`secrets.json` + `secret.key`) so an
|
|
// OpenCode cloud provider's API key never lands in plain JSON.
|
|
let secret_store = Arc::new(FsSecretStore::new(
|
|
Arc::clone(&fs_port),
|
|
app_data_dir.to_string_lossy().into_owned(),
|
|
));
|
|
let secret_store_port: Arc<dyn SecretStore> =
|
|
Arc::clone(&secret_store) as Arc<dyn SecretStore>;
|
|
|
|
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 save_opencode_provider_profile = Arc::new(SaveOpenCodeProviderProfile::new(
|
|
Arc::clone(&profile_store_port),
|
|
Arc::clone(&secret_store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
));
|
|
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
|
let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port)));
|
|
let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new());
|
|
let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir(
|
|
app_data_dir.clone(),
|
|
));
|
|
let list_claude_models = Arc::new(ListClaudeModels::new(
|
|
Arc::clone(&cli_version_reader) as Arc<dyn domain::ports::CliVersionReader>,
|
|
Arc::clone(&provider_model_catalogue) as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
|
Arc::clone(&compatibility_matrix) as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
|
));
|
|
let list_codex_models = Arc::new(ListCodexModels::new(
|
|
cli_version_reader as Arc<dyn domain::ports::CliVersionReader>,
|
|
provider_model_catalogue as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
|
compatibility_matrix as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
|
));
|
|
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
|
|
Arc::clone(&profile_store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
));
|
|
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),
|
|
Arc::clone(&secret_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 model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
|
|
as Arc<dyn domain::ports::ModelServerProbe>;
|
|
let ensure_local_model_server = Arc::new(
|
|
EnsureLocalModelServer::new(
|
|
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
|
|
Arc::clone(&model_server_probe_port),
|
|
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(Arc::clone(&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 model_artifact_downloader_port =
|
|
Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
|
|
let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
|
|
as Arc<dyn application::ModelArtifactDownloadTracker>;
|
|
let list_model_servers = Arc::new(
|
|
ListModelServers::new(Arc::clone(&model_server_registry_port))
|
|
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
|
|
.with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
|
|
);
|
|
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 bulk_update_issue_status = Arc::new(BulkUpdateIssueStatus::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let bulk_update_issue_priority = Arc::new(BulkUpdateIssuePriority::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let bulk_delete_issues = Arc::new(BulkDeleteIssues::new(
|
|
Arc::clone(&issue_store_port),
|
|
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 add_issue_attachment = Arc::new(AddIssueAttachment::new(
|
|
Arc::clone(&issue_store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
Arc::clone(&events_port),
|
|
));
|
|
let read_issue_attachment =
|
|
Arc::new(ReadIssueAttachment::new(Arc::clone(&issue_store_port)));
|
|
let mark_issue_attachment_summarized = Arc::new(MarkIssueAttachmentSummarized::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 template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new());
|
|
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_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 system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
|
|
let system_permission_store_port =
|
|
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
|
|
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
|
|
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
|
|
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(),
|
|
})
|
|
}),
|
|
Arc::clone(&secret_store_port),
|
|
)) 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),
|
|
)
|
|
.with_permission_store(Arc::clone(&permission_store_port))
|
|
.with_system_permission_store(Arc::clone(&system_permission_store_port)),
|
|
);
|
|
let close_ticket_assistant = Arc::new(CloseTicketAssistant::new(
|
|
Arc::clone(&structured_sessions),
|
|
tool_policy_store,
|
|
Arc::clone(&events_port),
|
|
));
|
|
|
|
// --- Project permissions (LP1) ---
|
|
let runtime_permission_probe =
|
|
Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
|
|
let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(
|
|
Arc::clone(&system_permission_store_port),
|
|
));
|
|
let update_project_system_permissions = Arc::new(UpdateProjectSystemPermissions::new(
|
|
Arc::clone(&system_permission_store_port),
|
|
));
|
|
let update_agent_system_permissions = Arc::new(UpdateAgentSystemPermissions::new(
|
|
Arc::clone(&system_permission_store_port),
|
|
));
|
|
let resolve_agent_system_permissions = Arc::new(ResolveAgentSystemPermissions::new(
|
|
Arc::clone(&system_permission_store_port),
|
|
Arc::clone(&runtime_permission_probe),
|
|
));
|
|
let mcp_tool_permission_store =
|
|
Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port)));
|
|
let mcp_tool_permission_store_port =
|
|
Arc::clone(&mcp_tool_permission_store) as Arc<dyn McpToolPermissionStore>;
|
|
let mcp_tool_catalogue = McpToolPermissionCatalogue::new(
|
|
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS
|
|
.iter()
|
|
.map(|tool| (*tool).to_owned())
|
|
.collect(),
|
|
infrastructure::orchestrator::mcp::tools::WRITE_ACTION_TOOLS
|
|
.iter()
|
|
.map(|tool| (*tool).to_owned())
|
|
.collect(),
|
|
)
|
|
.expect("MCP tool catalogue classification is valid");
|
|
let read_mcp_tool_permissions = Arc::new(ReadMcpToolPermissions::new(
|
|
Arc::clone(&mcp_tool_permission_store_port),
|
|
mcp_tool_catalogue.clone(),
|
|
));
|
|
let update_project_mcp_tool_permissions = Arc::new(UpdateProjectMcpToolPermissions::new(
|
|
Arc::clone(&mcp_tool_permission_store_port),
|
|
mcp_tool_catalogue.clone(),
|
|
));
|
|
let update_agent_mcp_tool_permissions = Arc::new(UpdateAgentMcpToolPermissions::new(
|
|
Arc::clone(&mcp_tool_permission_store_port),
|
|
mcp_tool_catalogue,
|
|
));
|
|
|
|
// --- 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 agent_capabilities =
|
|
Arc::new(ResolveAgentCapabilities::new(Arc::clone(&skill_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::with_capabilities(
|
|
Arc::clone(&contexts_port),
|
|
Arc::clone(&agent_capabilities),
|
|
));
|
|
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))
|
|
.with_system_permission_store(Arc::clone(&system_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))
|
|
.with_secret_store(Arc::clone(&secret_store_port)),
|
|
);
|
|
|
|
// 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_system_permission_store(Arc::clone(&system_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_secret_store(Arc::clone(&secret_store_port))
|
|
.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 update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_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 read_template = Arc::new(ReadTemplate::new(Arc::clone(&template_store_port)));
|
|
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` : compose
|
|
// le SkillStore et le manifeste agent afin d'autoriser uniquement les
|
|
// skills assignés au requester courant.
|
|
let read_skill = Arc::new(ReadSkill::new(
|
|
Arc::clone(&contexts_port),
|
|
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>,
|
|
)
|
|
.with_events(Arc::clone(&events_port)),
|
|
);
|
|
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 review_plugin_package = Arc::new(ReviewPluginPackage::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
let list_plugins = Arc::new(ListPlugins::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
Arc::clone(&events_port),
|
|
Arc::clone(&plugin_mcp_supervisor_port),
|
|
));
|
|
let install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
Arc::clone(&events_port),
|
|
Arc::clone(&plugin_mcp_supervisor_port),
|
|
));
|
|
let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
Arc::clone(&events_port),
|
|
Arc::clone(&plugin_mcp_supervisor_port),
|
|
));
|
|
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_storage_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&events_port),
|
|
Arc::clone(&plugin_mcp_supervisor_port),
|
|
));
|
|
let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
Arc::clone(&plugin_mcp_supervisor_port),
|
|
));
|
|
let plugin_workspace_access = Arc::new(
|
|
PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
|
.with_events(Arc::clone(&events_port)),
|
|
);
|
|
let plugin_config_documents = Arc::new(
|
|
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
|
.with_events(Arc::clone(&events_port)),
|
|
);
|
|
let plugin_storage_access = Arc::new(PluginStorageAccess::new(
|
|
Arc::clone(&plugin_storage_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
));
|
|
let query_project_structure = Arc::new(QueryProjectStructure::new(
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&fs_port),
|
|
));
|
|
let plugin_command_tasks = Arc::new(PluginCommandTasks::new(
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&background_tasks_port),
|
|
Arc::clone(&spawn_background_command),
|
|
));
|
|
let plugin_toolchain_diagnostics = Arc::new(PluginToolchainDiagnostics::new(
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&fs_port),
|
|
Arc::clone(&spawner_port),
|
|
Arc::clone(&environment_reader_port),
|
|
));
|
|
let plugin_event_subscriptions = Arc::new(PluginEventSubscriptions::new(
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
|
));
|
|
{
|
|
let mut rx = event_bus.raw_receiver();
|
|
let subscriptions = Arc::clone(&plugin_event_subscriptions);
|
|
spawn_detached(async move {
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(event) => subscriptions.record_domain_event(&event),
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
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(
|
|
domain::RuntimeAgentKey::new(ready.project_id, 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),
|
|
));
|
|
let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
|
|
Arc::clone(&model_server_registry_port),
|
|
Arc::clone(&model_server_probe_port),
|
|
Arc::clone(&model_artifact_downloader_port),
|
|
Arc::clone(&model_artifact_download_tracker),
|
|
Arc::clone(&profile_store_port),
|
|
Arc::clone(&store_port),
|
|
Arc::clone(&contexts_port),
|
|
Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
|
|
));
|
|
// 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)),
|
|
);
|
|
let get_app_exit_work_guard_state = Arc::new(GetAppExitWorkGuardState::new(Arc::clone(
|
|
&get_project_work_state,
|
|
)));
|
|
// 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>,
|
|
)),
|
|
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
|
Arc::clone(&file_guard),
|
|
Arc::clone(&contexts_port),
|
|
Arc::clone(&fs_port),
|
|
Arc::clone(&events_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),
|
|
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
|
|
Arc::clone(&mcp_tool_permission_store_port),
|
|
Arc::clone(&ticket_tool_provider),
|
|
Arc::clone(&template_tool_provider),
|
|
)) 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),
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
let open_plugin_layout_window = Arc::new(OpenPluginLayoutWindow::new(
|
|
Arc::clone(&plugin_package_store),
|
|
Arc::clone(&plugin_registry_store),
|
|
Arc::clone(&plugin_manifest_validator),
|
|
));
|
|
|
|
Self {
|
|
health,
|
|
pair_device,
|
|
pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
|
|
authenticate_session,
|
|
touch_device,
|
|
list_devices,
|
|
rename_device,
|
|
revoke_device,
|
|
revoke_all_devices,
|
|
device_session_store: Arc::clone(&device_session_port),
|
|
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,
|
|
open_plugin_layout_window,
|
|
snapshot_running_agents,
|
|
reconcile_layouts,
|
|
reconcile_live_state,
|
|
reconcile_background_tasks,
|
|
detect_profiles,
|
|
list_profiles,
|
|
save_profile,
|
|
save_opencode_provider_profile,
|
|
list_opencode_providers,
|
|
list_claude_models,
|
|
list_codex_models,
|
|
clone_profile_from_seed,
|
|
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,
|
|
delete_model_artifact,
|
|
pty_port,
|
|
terminal_sessions,
|
|
event_bus,
|
|
create_issue,
|
|
read_issue,
|
|
delete_issue,
|
|
list_issues,
|
|
update_issue,
|
|
bulk_update_issue_status,
|
|
bulk_update_issue_priority,
|
|
bulk_delete_issues,
|
|
read_issue_carnet,
|
|
update_issue_carnet,
|
|
add_issue_attachment,
|
|
read_issue_attachment,
|
|
mark_issue_attachment_summarized,
|
|
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,
|
|
template_tool_provider,
|
|
open_ticket_assistant,
|
|
close_ticket_assistant,
|
|
tool_policy_registry,
|
|
mcp_tool_permission_store: mcp_tool_permission_store_port,
|
|
read_mcp_tool_permissions,
|
|
update_project_mcp_tool_permissions,
|
|
update_agent_mcp_tool_permissions,
|
|
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,
|
|
get_app_exit_work_guard_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,
|
|
update_agent_effort,
|
|
resolve_agent_permissions,
|
|
get_project_system_permissions,
|
|
update_project_system_permissions,
|
|
update_agent_system_permissions,
|
|
resolve_agent_system_permissions,
|
|
create_template,
|
|
read_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_command_runner: Arc::clone(&background_runner),
|
|
background_task_store: Arc::clone(&background_tasks_port),
|
|
review_plugin_package,
|
|
install_plugin_from_archive,
|
|
install_plugin_from_directory,
|
|
list_plugins,
|
|
set_plugin_enabled,
|
|
uninstall_plugin,
|
|
list_plugin_runtime_contributions,
|
|
reconcile_plugin_mcp_servers,
|
|
plugin_workspace_access,
|
|
plugin_config_documents,
|
|
plugin_storage_access,
|
|
query_project_structure,
|
|
plugin_command_tasks,
|
|
plugin_toolchain_diagnostics,
|
|
plugin_event_subscriptions,
|
|
plugin_package_store: Arc::clone(&plugin_packages),
|
|
plugin_registry_store: Arc::clone(&plugin_registry_store),
|
|
plugin_manifest_validator: Arc::clone(&plugin_manifest_validator),
|
|
ticket_tool_binder,
|
|
template_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_project = project.clone();
|
|
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(&ready_project, 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_template_tools(Arc::clone(&self.template_tool_provider))
|
|
.with_tool_policies(Arc::clone(&self.tool_policy_registry))
|
|
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
|
|
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_id: ProjectId,
|
|
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(domain::RuntimeAgentKey::new(project_id, 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(domain::RuntimeAgentKey::new(project_id, agent_id), handle);
|
|
}
|
|
}
|
|
|
|
/// Stops and removes the end-of-turn watcher of `agent_id` in `project_id` (close /
|
|
/// stop). Dropping the stored handle stops its polling task. No-op if none is armed.
|
|
pub fn stop_turn_watch(&self, project_id: ProjectId, agent_id: AgentId) {
|
|
if let Ok(mut map) = self.turn_watch_handles.lock() {
|
|
map.remove(&domain::RuntimeAgentKey::new(project_id, 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 = replace_toml_table_block(
|
|
&text,
|
|
CODEX_CODE_MODE_FEATURES_TABLE,
|
|
CODEX_CODE_MODE_FEATURES_TOML.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"
|
|
user_key = "keep-me"
|
|
|
|
[mcp_servers.idea]
|
|
command = "stale"
|
|
args = ["mcp-server"]
|
|
transport = "stdio"
|
|
|
|
[features.code_mode]
|
|
direct_only_tool_namespaces = ["old"]
|
|
|
|
[features.preview]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.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(r#"user_key = "keep-me""#));
|
|
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("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]")
|
|
);
|
|
assert_eq!(config.matches("[features.code_mode]").count(), 1);
|
|
assert!(!config.contains(r#"command = "stale""#));
|
|
assert!(!config.contains(r#"direct_only_tool_namespaces = ["old"]"#));
|
|
assert!(config.contains("[features.preview]\nenabled = true"));
|
|
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(),
|
|
effort: None,
|
|
});
|
|
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();
|
|
let expected_names: Vec<&str> = infrastructure::orchestrator::mcp::catalogue()
|
|
.into_iter()
|
|
.map(|tool| tool.name)
|
|
.collect();
|
|
assert!(!names.contains(&"idea_reply"));
|
|
assert_eq!(
|
|
names, expected_names,
|
|
"tools/list must expose exactly the canonical MCP catalogue"
|
|
);
|
|
|
|
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::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
|
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),
|
|
)),
|
|
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
|
Arc::clone(&file_guard),
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(FakeFs),
|
|
Arc::new(NoopBus),
|
|
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(),
|
|
effort: None,
|
|
});
|
|
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(&domain::RuntimeAgentKey::new(proj.id, 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(&domain::RuntimeAgentKey::new(proj.id, 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)"
|
|
);
|
|
}
|
|
}
|