refactor(backend): extraire le cœur backend commun hors Tauri (#13)
Lot B1 du chantier server/client mode : création de la crate `backend` qui héberge le cœur commun (endpoint MCP, outils OpenAI) indépendant de Tauri, préalable au futur serveur web + PTY WebSocket. - crates/backend : nouvelle crate (lib.rs, mcp_endpoint.rs, openai_tools.rs). - crates/app-tauri : câblage sur la crate backend (state.rs, mcp_bridge.rs, Cargo.toml, tests/orchestrator_wiring.rs). - Cargo.toml / Cargo.lock racine : ajout de la crate au workspace. Validé : cargo check --workspace vert, tests backend/app-tauri verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -23,6 +23,7 @@ tauri-build = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
backend = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
# `io-std` (on top of the workspace features) gives the headless `mcp-server`
|
||||
@ -44,8 +45,8 @@ interprocess = { version = "2.4", features = ["tokio"] }
|
||||
[features]
|
||||
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
||||
# (founding posture: `none` ⇒ naïve recall, zero dependency).
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx"]
|
||||
vector-http = ["infrastructure/vector-http", "backend/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx", "backend/vector-onnx"]
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true }
|
||||
|
||||
@ -600,6 +600,7 @@ mod tests {
|
||||
/// the server (reads the handshake, echoes a canned response); the relay drives
|
||||
/// it over an actual connection. Verifies the handshake crosses the real socket
|
||||
/// and the response returns on the CLI stdout.
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn end_to_end_over_real_loopback() {
|
||||
let endpoint = temp_endpoint("e2e");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -242,6 +242,7 @@ fn socket_exists(project: &Project) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn open_binds_the_project_loopback_endpoint() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -259,6 +260,7 @@ async fn open_binds_the_project_loopback_endpoint() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -280,6 +282,7 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn close_cleans_up_the_endpoint_socket_file() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -311,6 +314,7 @@ fn endpoint_is_deterministic_and_collision_free_across_projects() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn file_watcher_and_loopback_endpoint_live_together() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
|
||||
22
crates/backend/Cargo.toml
Normal file
22
crates/backend/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "backend"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "IdeA shared backend composition root, reusable by desktop and server adapters."
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-std", "rt"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
interprocess = { version = "2.4", features = ["tokio"] }
|
||||
|
||||
[features]
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx"]
|
||||
6399
crates/backend/src/lib.rs
Normal file
6399
crates/backend/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
317
crates/backend/src/mcp_endpoint.rs
Normal file
317
crates/backend/src/mcp_endpoint.rs
Normal file
@ -0,0 +1,317 @@
|
||||
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
|
||||
//!
|
||||
//! ## Where this sits in the bind (cadrage v5 §1, §2)
|
||||
//!
|
||||
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
|
||||
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
|
||||
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
|
||||
//! every line, over a **local loopback**, to the IdeA desktop/server process where the
|
||||
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
|
||||
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
|
||||
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
|
||||
//! needs no firewall/permission.
|
||||
//!
|
||||
//! This module owns the **single source of truth** for that loopback address:
|
||||
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
|
||||
//! **both** sides of the contract (cadrage §2):
|
||||
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
|
||||
//! which binds the listener at project-open and tears it down at close (M5a);
|
||||
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
|
||||
//! which must point the spawned bridge at the **exact** same address.
|
||||
//!
|
||||
//! Keeping the derivation here (not duplicated on each side) is what makes the
|
||||
//! M1↔M3 coherence test possible.
|
||||
//!
|
||||
//! ## Transport crate choice — `interprocess`
|
||||
//!
|
||||
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
|
||||
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
|
||||
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
|
||||
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
|
||||
//! - The workspace `tokio` is built **without** the `net` feature; pulling
|
||||
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
|
||||
//! widening tokio's surface workspace-wide.
|
||||
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
|
||||
//! file on drop** — so closing a project (dropping the handle) cleans the
|
||||
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
|
||||
//!
|
||||
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
|
||||
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
|
||||
//! the endpoint is observable as a real path (testable) and cleaned up on close.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use application::{McpRuntime, McpRuntimeProvider};
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
||||
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
||||
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
|
||||
///
|
||||
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
|
||||
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
|
||||
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
|
||||
/// the exact string handed to the spawned bridge via `--endpoint`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpEndpoint {
|
||||
/// The platform-native address string (UDS path / named-pipe path).
|
||||
addr: String,
|
||||
}
|
||||
|
||||
impl McpEndpoint {
|
||||
/// The address as the bridge expects it on the command line (`--endpoint`).
|
||||
/// Identical string on both sides of the contract (cadrage §2).
|
||||
#[must_use]
|
||||
pub fn as_cli_arg(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
|
||||
/// Used by the listener to ensure the parent runtime dir exists and by tests to
|
||||
/// assert creation/cleanup. `None` on platforms whose address is not a path
|
||||
/// (Windows named pipes have no filesystem entry to manage).
|
||||
#[must_use]
|
||||
pub fn socket_path(&self) -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(PathBuf::from(&self.addr))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory under which per-project Unix sockets live, derived from the
|
||||
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
|
||||
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
|
||||
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
|
||||
///
|
||||
/// The candidate bases are tried **in priority order**, returning the first whose
|
||||
/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR`
|
||||
/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not
|
||||
/// dead-end the loopback bind: without this fallback the socket silently never
|
||||
/// binds and inter-agent delegation dies. Deterministic for a given environment, so
|
||||
/// the bind side and any `socket_path()` reader always agree on the same directory.
|
||||
#[cfg(unix)]
|
||||
fn unix_runtime_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let dir = std::env::temp_dir().join("idea-mcp");
|
||||
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
|
||||
std::env::var_os("TMPDIR").map(PathBuf::from),
|
||||
Some(PathBuf::from("/tmp")),
|
||||
];
|
||||
for base in candidates.into_iter().flatten() {
|
||||
let dir = base.join("idea-mcp");
|
||||
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
// Last resort: keep the historical `/tmp` target even if creation just failed
|
||||
// (the bind will surface the real error rather than silently picking nowhere).
|
||||
PathBuf::from("/tmp").join("idea-mcp")
|
||||
}
|
||||
|
||||
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
|
||||
/// project's MCP transport from its [`ProjectId`].
|
||||
///
|
||||
/// Same input ⇒ same output (stable across calls and processes); distinct projects
|
||||
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
|
||||
/// by construction). No network port is ever used.
|
||||
///
|
||||
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
|
||||
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
|
||||
///
|
||||
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
|
||||
#[must_use]
|
||||
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
|
||||
// a `\\.\pipe\` name, and unique per project.
|
||||
let id = project_id.as_uuid().simple().to_string();
|
||||
|
||||
#[cfg(unix)]
|
||||
let addr = unix_runtime_dir()
|
||||
.join(format!("{id}.sock"))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
#[cfg(windows)]
|
||||
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let addr = format!("idea-mcp-{id}");
|
||||
|
||||
McpEndpoint { addr }
|
||||
}
|
||||
|
||||
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
||||
///
|
||||
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
||||
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implementation of the [`McpRuntimeProvider`] port: fournit à
|
||||
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
||||
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
||||
/// `ask` (`ensure_live_pty`).
|
||||
///
|
||||
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
||||
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
||||
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
||||
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
||||
/// traversent la frontière via [`McpRuntime`].
|
||||
pub struct AppMcpRuntimeProvider;
|
||||
|
||||
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
||||
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
||||
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
||||
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
||||
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
||||
/// lancement.
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
||||
Some(McpRuntime {
|
||||
exe: idea_exe_path()?,
|
||||
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn pid(s: &str) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_deterministic_for_the_same_project() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
|
||||
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_projects_get_distinct_endpoints() {
|
||||
let a = pid("11111111-1111-1111-1111-111111111111");
|
||||
let b = pid("22222222-2222-2222-2222-222222222222");
|
||||
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
|
||||
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_encodes_the_project_id_without_hyphens() {
|
||||
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
|
||||
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
|
||||
assert!(
|
||||
arg.contains("abcdef0123456789abcdef0123456789"),
|
||||
"endpoint must embed the hyphen-free id, got {arg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
|
||||
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
||||
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
||||
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
||||
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
||||
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
||||
#[test]
|
||||
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
||||
let saved = std::env::var_os("APPIMAGE");
|
||||
|
||||
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
||||
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
||||
assert_eq!(
|
||||
idea_exe_path().as_deref(),
|
||||
Some("/opt/idea/IdeA.AppImage"),
|
||||
"$APPIMAGE doit primer"
|
||||
);
|
||||
|
||||
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
||||
std::env::remove_var("APPIMAGE");
|
||||
let fallback = idea_exe_path();
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
||||
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
||||
|
||||
// Restauration de l'état initial de la variable.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("APPIMAGE", v),
|
||||
None => std::env::remove_var("APPIMAGE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
||||
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap();
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
|
||||
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
||||
let rt = AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
||||
|
||||
// Endpoint = même source de vérité que le listener.
|
||||
assert_eq!(
|
||||
rt.endpoint,
|
||||
mcp_endpoint(&project.id).as_cli_arg(),
|
||||
"endpoint cohérent avec mcp_endpoint()"
|
||||
);
|
||||
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
||||
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
||||
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
||||
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
||||
// requester = l'id de la cible relancée.
|
||||
assert_eq!(rt.requester, agent_id.to_string());
|
||||
// exe non vide.
|
||||
assert!(!rt.exe.is_empty(), "exe renseigné");
|
||||
}
|
||||
}
|
||||
149
crates/backend/src/openai_tools.rs
Normal file
149
crates/backend/src/openai_tools.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! ToolInvoker for the OpenAI-compatible adapter.
|
||||
//!
|
||||
//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que
|
||||
//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même
|
||||
//! `OrchestratorService::dispatch`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::OrchestratorService;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec};
|
||||
use serde_json::Value;
|
||||
|
||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||
const REQUESTER_ARG: &str = "__ideaRequester";
|
||||
|
||||
/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif.
|
||||
pub struct AppOpenAiToolInvoker {
|
||||
orchestrator: Arc<OrchestratorService>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||
/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime.
|
||||
#[derive(Default)]
|
||||
pub struct LateBoundOpenAiToolInvoker {
|
||||
inner: Mutex<Option<Arc<dyn ToolInvoker>>>,
|
||||
}
|
||||
|
||||
impl LateBoundOpenAiToolInvoker {
|
||||
/// Construit un proxy vide.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lie l'implémentation réelle. Appelé une fois par la composition root.
|
||||
pub fn bind(&self, inner: Arc<dyn ToolInvoker>) {
|
||||
*self.inner.lock().expect("mutex sain") = Some(inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolInvoker for LateBoundOpenAiToolInvoker {
|
||||
fn tools(&self) -> Vec<ToolSpec> {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |inner| inner.tools())
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
|
||||
})?;
|
||||
inner.call(name, args_json).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AppOpenAiToolInvoker {
|
||||
/// Construit l'invoker depuis le service orchestrateur et le store projet.
|
||||
#[must_use]
|
||||
pub fn new(orchestrator: Arc<OrchestratorService>, projects: Arc<dyn ProjectStore>) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
fn tools(&self) -> Vec<ToolSpec> {
|
||||
infrastructure::orchestrator::mcp::catalogue()
|
||||
.into_iter()
|
||||
.map(|tool| ToolSpec {
|
||||
name: tool.name.to_owned(),
|
||||
description: tool.description.to_owned(),
|
||||
input_schema: tool.input_schema,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let value: Value = serde_json::from_str(args_json)
|
||||
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
|
||||
let args = value.as_object().ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"les arguments d'outil doivent être un objet JSON".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let project_root = args
|
||||
.get(PROJECT_ROOT_ARG)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"contexte projet interne absent pour l'outil".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let requester = args
|
||||
.get(REQUESTER_ARG)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"identité requester interne absente pour l'outil".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let project = self
|
||||
.projects
|
||||
.list_projects()
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
|
||||
.into_iter()
|
||||
.find(|project| project.root.as_str() == project_root)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution(format!(
|
||||
"projet introuvable pour root `{project_root}`"
|
||||
))
|
||||
})?;
|
||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester)
|
||||
.map_err(|e| match e {
|
||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||
ToolInvocationError::NotFound(tool)
|
||||
}
|
||||
infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => {
|
||||
ToolInvocationError::InvalidArguments(format!(
|
||||
"arguments invalides pour `{tool}`"
|
||||
))
|
||||
}
|
||||
infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => {
|
||||
ToolInvocationError::InvalidArguments(err.to_string())
|
||||
}
|
||||
})?;
|
||||
let outcome = self
|
||||
.orchestrator
|
||||
.dispatch(&project, command)
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?;
|
||||
Ok(outcome.reply.unwrap_or(outcome.detail))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user