Le first-run wizard restait figé : `CliAgentRuntime::detect` construisait un runtime tokio courant-thread via `futures_block_on` pour piloter un `ProcessSpawner` async. Appelé depuis le runtime async de Tauri, ce `block_on` imbriqué panique ; la commande IPC ne répond alors jamais, la promesse `detectProfiles` reste pendante et `busy` ne redescend plus. Ce commit s'attaque à la cause côté backend : - `AgentRuntime::detect` devient `async fn` (`#[async_trait]`) : le port cesse de mentir sur sa nature. Il pilote un spawner async, il est async. Le `futures_block_on` disparaît, et avec lui le runtime imbriqué. - La sonde CLI est bornée par `tokio::time::timeout(DETECTION_TIMEOUT)` : un binaire qui ne rend jamais la main dégrade la détection en `Err`, il ne gèle plus l'appelant. - Les profils `StructuredAdapter::OpenAiCompatible` n'ont pas de CLI à spawner : les sonder revenait à tester un binaire inexistant. Ils sont désormais sondés par un GET HTTP sur l'endpoint `/models` dérivé de `chat_http.endpoint`, borné par le même timeout. `DetectProfiles` séquence les `await` sur les candidats. Les fakes `AgentRuntime` des crates application/infrastructure/app-tauri suivent la nouvelle signature. Tests: cargo test -p domain (241), -p application (81), -p infrastructure (269), -p app-tauri (63+15) — verts. `rg futures_block_on crates` sans match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
11 KiB
Rust
262 lines
11 KiB
Rust
//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by
|
|
//! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9).
|
|
//!
|
|
//! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex,
|
|
//! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`]
|
|
//! data, never in code. Adding an AI = adding a profile (the **Open/Closed**
|
|
//! principle made literal).
|
|
//!
|
|
//! # Responsibilities
|
|
//!
|
|
//! - [`detect`](AgentRuntime::detect): run the profile's detection command via
|
|
//! the injected [`ProcessSpawner`] and report whether the CLI is installed
|
|
//! (exit code 0). When `profile.detect` is `None`, fall back to
|
|
//! `<command> --version` (see [`detection_spec`]).
|
|
//! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure**
|
|
//! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved
|
|
//! `cwd` (template substitution), and the context-injection plan derived from
|
|
//! the profile's [`ContextInjection`]. This is the testable core of L5.
|
|
|
|
use std::{sync::Arc, time::Duration};
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::ports::{
|
|
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
|
|
SpawnSpec,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
|
|
const DETECTION_TIMEOUT: Duration = Duration::from_millis(800);
|
|
|
|
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
|
|
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
|
|
#[derive(Clone)]
|
|
pub struct CliAgentRuntime {
|
|
spawner: Arc<dyn ProcessSpawner>,
|
|
}
|
|
|
|
impl CliAgentRuntime {
|
|
/// Builds the runtime from an injected [`ProcessSpawner`] (the composition
|
|
/// root passes a [`crate::LocalProcessSpawner`], or a remote one later).
|
|
#[must_use]
|
|
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
|
|
Self { spawner }
|
|
}
|
|
|
|
/// Builds the [`SpawnSpec`] used to *detect* a profile's CLI.
|
|
///
|
|
/// - If `profile.detect` is set, it is parsed as a whitespace-delimited
|
|
/// command line (`"claude --version"` → command `claude`, args
|
|
/// `["--version"]`). The first token is the executable.
|
|
/// - Otherwise we fall back to `<command> --version`, the near-universal
|
|
/// "is it installed?" probe.
|
|
///
|
|
/// Detection runs in the current working directory and injects nothing.
|
|
///
|
|
/// This is pure (no I/O) and therefore unit-testable without a spawner.
|
|
///
|
|
/// # Errors
|
|
/// [`RuntimeError::Detection`] if the detection command is blank.
|
|
pub fn detection_spec(profile: &AgentProfile) -> Result<SpawnSpec, RuntimeError> {
|
|
let line = profile
|
|
.detect
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{} --version", profile.command));
|
|
let mut tokens = line.split_whitespace();
|
|
let command = tokens
|
|
.next()
|
|
.ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))?
|
|
.to_owned();
|
|
let args = tokens.map(str::to_owned).collect();
|
|
// Detection runs in a neutral cwd; "." is a safe relative placeholder the
|
|
// spawner resolves against the process cwd.
|
|
let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
|
Ok(SpawnSpec {
|
|
command,
|
|
args,
|
|
cwd,
|
|
env: Vec::new(),
|
|
context_plan: None,
|
|
sandbox: None,
|
|
})
|
|
}
|
|
|
|
/// Resolves the profile's `cwd_template` against the supplied `base` cwd.
|
|
///
|
|
/// The base is the agent's **run directory** (`.ideai/run/<agent-id>/`),
|
|
/// already computed (and created) by `LaunchAgent` and passed as the `cwd`
|
|
/// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The
|
|
/// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy
|
|
/// `{projectRoot}` is still substituted with the same base for backwards
|
|
/// compatibility (the caller always passes the run dir now). An empty template
|
|
/// defaults to the base itself.
|
|
fn resolve_cwd(
|
|
profile: &AgentProfile,
|
|
base: &ProjectPath,
|
|
) -> Result<ProjectPath, RuntimeError> {
|
|
let template = profile.cwd_template.trim();
|
|
if template.is_empty() {
|
|
return Ok(base.clone());
|
|
}
|
|
let resolved = template
|
|
.replace("{agentRunDir}", base.as_str())
|
|
.replace("{projectRoot}", base.as_str());
|
|
ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string()))
|
|
}
|
|
|
|
/// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and
|
|
/// the prepared context. **Pure** — the testable heart of L5.
|
|
///
|
|
/// | `ContextInjection` | `ContextInjectionPlan` |
|
|
/// |---------------------------|----------------------------------------------------------|
|
|
/// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there |
|
|
/// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path |
|
|
/// | `Stdin` | `Stdin` — content piped on stdin |
|
|
/// | `Env { var }` | `Env { var }` — content/path delivered via env var |
|
|
///
|
|
/// For `Flag`, the flag template may contain `{path}` (replaced by the
|
|
/// context's relative path). A flag *without* `{path}` is treated as a
|
|
/// standalone switch followed by the path as a separate argument
|
|
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
|
|
/// whitespace after substitution (e.g. `--context-file {path}` →
|
|
/// `["--context-file", "<path>"]`).
|
|
fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
|
|
match injection {
|
|
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
|
|
target: target.clone(),
|
|
},
|
|
ContextInjection::Flag { flag } => {
|
|
let path = &ctx.relative_path;
|
|
let args = if flag.contains("{path}") {
|
|
flag.replace("{path}", path)
|
|
.split_whitespace()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
} else {
|
|
vec![flag.clone(), path.clone()]
|
|
};
|
|
ContextInjectionPlan::Args { args }
|
|
}
|
|
ContextInjection::Stdin => ContextInjectionPlan::Stdin,
|
|
ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
|
|
}
|
|
}
|
|
|
|
/// Composes the session resume/assign arguments for a launch. **Pure** — the
|
|
/// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan).
|
|
///
|
|
/// The profile's optional [`SessionStrategy`] crossed with the per-launch
|
|
/// [`SessionPlan`] yields the args to append (exhaustive truth table):
|
|
///
|
|
/// | `profile.session` | `SessionPlan` | Args added |
|
|
/// |----------------------------------------|----------------|------------|
|
|
/// | `None` | any | `[]` |
|
|
/// | `Some{assign_flag: Some(f), ..}` | `Assign{id}` | `[f, id]` |
|
|
/// | `Some{resume_flag: r, ..}` | `Resume{id}` | `[r, id]` |
|
|
/// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) |
|
|
/// | `Some{..}` | `None` | `[]` |
|
|
/// | `Some{assign_flag: None, ..}` | `Assign{id}` | `[]` (no flag) |
|
|
fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec<String> {
|
|
let Some(strategy) = session else {
|
|
return Vec::new();
|
|
};
|
|
match plan {
|
|
SessionPlan::None => Vec::new(),
|
|
SessionPlan::Assign { conversation_id } => match &strategy.assign_flag {
|
|
Some(flag) => vec![flag.clone(), conversation_id.clone()],
|
|
None => Vec::new(),
|
|
},
|
|
SessionPlan::Resume { conversation_id } => {
|
|
let mut args = vec![strategy.resume_flag.clone()];
|
|
if strategy.assign_flag.is_some() {
|
|
args.push(conversation_id.clone());
|
|
}
|
|
args
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for CliAgentRuntime {
|
|
async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) {
|
|
return Ok(Self::detect_openai_compatible(profile).await);
|
|
}
|
|
|
|
let spec = Self::detection_spec(profile)?;
|
|
let spawner = Arc::clone(&self.spawner);
|
|
let output = tokio::time::timeout(DETECTION_TIMEOUT, spawner.run(spec))
|
|
.await
|
|
.map_err(|_| RuntimeError::Detection("detection timed out".to_owned()))?
|
|
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
|
Ok(output.status.code == Some(0))
|
|
}
|
|
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
|
|
let plan = Self::injection_plan(&profile.context_injection, ctx);
|
|
|
|
// For the `Flag` strategy the context path travels *on the command line*,
|
|
// so fold those args into the spec's args (after the profile's static
|
|
// args). The other strategies leave args untouched; the plan tells the
|
|
// launcher (L6) what else to do (write file / pipe stdin / set env).
|
|
let mut args = profile.args.clone();
|
|
if let ContextInjectionPlan::Args { args: extra } = &plan {
|
|
args.extend(extra.iter().cloned());
|
|
}
|
|
|
|
// Session resume/assign args come *after* the static + context-injection
|
|
// args, so re-opening a conversation never disturbs context delivery.
|
|
args.extend(Self::session_args(profile.session.as_ref(), session));
|
|
|
|
Ok(SpawnSpec {
|
|
command: profile.command.clone(),
|
|
args,
|
|
cwd: resolved_cwd,
|
|
env: Vec::new(),
|
|
context_plan: Some(plan),
|
|
sandbox: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl CliAgentRuntime {
|
|
async fn detect_openai_compatible(profile: &AgentProfile) -> bool {
|
|
let Some(chat) = &profile.chat_http else {
|
|
return false;
|
|
};
|
|
let url = openai_compatible_models_endpoint(&chat.endpoint);
|
|
let probe = async move {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(DETECTION_TIMEOUT)
|
|
.build()
|
|
.ok()?;
|
|
let response = client.get(url).send().await.ok()?;
|
|
Some(response.status().is_success())
|
|
};
|
|
tokio::time::timeout(DETECTION_TIMEOUT, probe)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
fn openai_compatible_models_endpoint(endpoint: &str) -> String {
|
|
let trimmed = endpoint.trim_end_matches('/');
|
|
let base = trimmed
|
|
.strip_suffix("/chat/completions")
|
|
.unwrap_or(trimmed)
|
|
.trim_end_matches('/');
|
|
format!("{base}/models")
|
|
}
|