chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -9,19 +9,16 @@ use tauri::State;
|
||||
|
||||
use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||
use application::{
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput,
|
||||
CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput,
|
||||
DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
|
||||
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions,
|
||||
McpRuntime,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||
OpenProjectInput,
|
||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||
ReconcileLayoutsInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput,
|
||||
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput,
|
||||
CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
|
||||
DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput,
|
||||
DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput,
|
||||
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
|
||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput,
|
||||
McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
|
||||
ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
|
||||
};
|
||||
@ -30,27 +27,27 @@ use domain::ports::PtyHandle;
|
||||
use crate::dto::{
|
||||
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
|
||||
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
|
||||
parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
|
||||
AttachLiveAgentRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, SubmitAgentInputRequestDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResumableAgentListDto,
|
||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||
MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto,
|
||||
ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SkillDto, SkillListDto, SyncAgentWithTemplateRequestDto,
|
||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
||||
UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateMemoryRequestDto,
|
||||
UpdateProjectContextRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
use crate::state::AppState;
|
||||
@ -122,6 +119,7 @@ pub async fn open_project(
|
||||
.await;
|
||||
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
|
||||
state.ensure_orchestrator_watch(&output.project);
|
||||
state.reconcile_claude_run_dirs(&output.project).await;
|
||||
Ok(ProjectDto::from(output))
|
||||
}
|
||||
|
||||
@ -1049,6 +1047,11 @@ pub async fn launch_agent(
|
||||
requester: agent_id.to_string(),
|
||||
});
|
||||
|
||||
// Functional migration seam: before any launch/reattach/idempotent early-return,
|
||||
// repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json`
|
||||
// artefacts from previous AppImages do not survive into this activation.
|
||||
state.reconcile_claude_run_dirs(&project).await;
|
||||
|
||||
let output = state
|
||||
.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
@ -1206,31 +1209,6 @@ pub async fn agent_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which
|
||||
/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations
|
||||
/// use (serialised per agent). Fire-and-forget: the human watches the terminal for
|
||||
/// the answer. The mediator emits `AgentBusyChanged` at the source.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
|
||||
/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure).
|
||||
#[tauri::command]
|
||||
pub async fn submit_agent_input(
|
||||
request: SubmitAgentInputRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.submit_human_input(&project, agent_id, request.text)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
|
||||
///
|
||||
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
|
||||
@ -1255,6 +1233,32 @@ pub async fn interrupt_agent(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
|
||||
///
|
||||
/// Called once the cell has physically written a delegation `ticket` into the agent's
|
||||
/// native PTY (text + submit sequence). Routes to the best-effort
|
||||
/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does
|
||||
/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`,
|
||||
/// and timeouts/cycle guards are untouched. Infallible on the application side; only a
|
||||
/// malformed id (project/agent/ticket) yields an `INVALID` error here.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is
|
||||
/// unknown).
|
||||
#[tauri::command]
|
||||
pub async fn delegation_delivered(
|
||||
request: DeliveredDelegationRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
let ticket = parse_ticket_id(&request.ticket)?;
|
||||
state
|
||||
.orchestrator_service
|
||||
.note_delegation_delivered(&project, agent_id, ticket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
|
||||
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
|
||||
///
|
||||
|
||||
@ -1197,20 +1197,6 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path.
|
||||
/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends
|
||||
/// `{ request: { projectId, agentId, text } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitAgentInputRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent to send the human input to.
|
||||
pub agent_id: String,
|
||||
/// The text the operator typed (enqueued as a `from_human` ticket).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1222,6 +1208,22 @@ pub struct InterruptAgentRequestDto {
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write-
|
||||
/// portal acks that it **physically wrote** a delegation `ticket` into the agent's
|
||||
/// native PTY. Best-effort observability — it never changes correlation (the `ask` is
|
||||
/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId,
|
||||
/// ticket } }`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliveredDelegationRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose terminal received the delegation.
|
||||
pub agent_id: String,
|
||||
/// Id of the delivered mailbox ticket.
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
|
||||
/// profile, optionally relaunching its live session in place.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@ -1435,6 +1437,19 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(domain::TicketId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid ticket id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resumable agents (§15.2 — Chantier B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -56,6 +56,26 @@ pub enum DomainEventDto {
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
||||
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
||||
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
||||
/// the turn.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DelegationReady {
|
||||
/// Target agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
||||
ticket: String,
|
||||
/// Task text to inject (written without a trailing newline by the portal).
|
||||
text: String,
|
||||
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_sequence: Option<String>,
|
||||
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
@ -225,6 +245,19 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
},
|
||||
DomainEvent::DelegationReady {
|
||||
agent_id,
|
||||
ticket,
|
||||
text,
|
||||
submit_sequence,
|
||||
submit_delay_ms,
|
||||
} => Self::DelegationReady {
|
||||
agent_id: agent_id.to_string(),
|
||||
ticket: ticket.to_string(),
|
||||
text: text.clone(),
|
||||
submit_sequence: submit_sequence.clone(),
|
||||
submit_delay_ms: *submit_delay_ms,
|
||||
},
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
|
||||
@ -44,15 +44,10 @@ pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args
|
||||
.next()
|
||||
.is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND)
|
||||
{
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
@ -166,8 +161,8 @@ pub fn run() {
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::submit_agent_input,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
|
||||
@ -49,14 +49,18 @@ use std::time::Duration;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// After the CLI closes stdin, how long to keep draining the loopback so an
|
||||
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
|
||||
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
|
||||
/// loopback fd at process exit anyway.
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
@ -222,18 +226,26 @@ async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback;
|
||||
/// read one **line** from the loopback → write it to the CLI (`cli_out`).
|
||||
/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the
|
||||
/// loopback writer closes that direction of the connection.
|
||||
/// 2. Run **two independent directional pumps concurrently** (full duplex):
|
||||
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
|
||||
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
|
||||
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
|
||||
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
|
||||
///
|
||||
/// The loop is request/response paced (one CLI line in, one loopback line back),
|
||||
/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that
|
||||
/// closes mid-exchange surfaces as an error (non-zero exit).
|
||||
/// ## Why full duplex (and not lockstep)
|
||||
///
|
||||
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
|
||||
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
|
||||
/// **no response**, and the server may push messages unsolicited. A lockstep pump
|
||||
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
|
||||
/// on the first notification: it waits forever for a reply that never comes, and
|
||||
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
|
||||
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
|
||||
/// server messages flow freely in both directions.
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
mut cli_out: COut,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
@ -253,48 +265,66 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
let mut cli_reader = BufReader::new(cli_in);
|
||||
let mut lp_reader = BufReader::new(lp_read);
|
||||
let mut cli_line = String::new();
|
||||
let mut lp_line = String::new();
|
||||
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
// CLI closed stdin (or errored): half-close the loopback writer to nudge
|
||||
// the server, then drain briefly so an in-flight response still reaches the
|
||||
// CLI — but never block on the server (bounded by DRAIN_GRACE).
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
// Loopback closed first (server hangup): nothing left to relay. The CLI's
|
||||
// stdin EOF is no longer needed to exit.
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
|
||||
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
|
||||
///
|
||||
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
|
||||
/// it. `src`/`dst` name the two ends for error messages only.
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
// 2a. CLI → loopback (one line). EOF ⇒ clean shutdown.
|
||||
cli_line.clear();
|
||||
let n = cli_reader
|
||||
.read_line(&mut cli_line)
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("stdin read failed: {e}"))?;
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// CLI closed stdin: nothing more to forward. Clean exit.
|
||||
return Ok(());
|
||||
// Source closed: drain and hand the writer back for half-close.
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
lp_write
|
||||
.write_all(cli_line.as_bytes())
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("loopback write failed: {e}"))?;
|
||||
lp_write
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("loopback flush failed: {e}"))?;
|
||||
|
||||
// 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange.
|
||||
lp_line.clear();
|
||||
let m = lp_reader
|
||||
.read_line(&mut lp_line)
|
||||
.await
|
||||
.map_err(|e| format!("loopback read failed: {e}"))?;
|
||||
if m == 0 {
|
||||
return Err("loopback closed before answering".to_string());
|
||||
}
|
||||
cli_out
|
||||
.write_all(lp_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("stdout write failed: {e}"))?;
|
||||
cli_out
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("stdout flush failed: {e}"))?;
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,7 +343,12 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7",
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
@ -355,8 +390,7 @@ mod tests {
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
@ -393,24 +427,19 @@ mod tests {
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap();
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(
|
||||
&args,
|
||||
&cli_in[..],
|
||||
&mut cli_out,
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
@ -420,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (the bug that hid the tool list for days): a **notification**
|
||||
/// (no `id`, no response) sent between two requests must NOT stall the relay.
|
||||
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
|
||||
/// waiting for a reply that never comes, and never reads `tools/list`. The
|
||||
/// full-duplex relay forwards all three lines and delivers the one response.
|
||||
#[tokio::test]
|
||||
async fn relay_does_not_block_on_notification_without_response() {
|
||||
// initialize result, then an unanswered notification, then tools/list.
|
||||
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
|
||||
{\"method\":\"notifications/initialized\"}\n\
|
||||
{\"method\":\"tools/list\",\"id\":2}\n"
|
||||
.to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: "agent".into(),
|
||||
};
|
||||
|
||||
// Server: handshake, then read all three forwarded lines, answering only
|
||||
// the two that carry an `id`. The notification gets no reply — exactly the
|
||||
// case that used to wedge the relay.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut w = srv_write;
|
||||
for _ in 0..4 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // handshake line
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not deadlock on a notification")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let out = String::from_utf8(cli_out).unwrap();
|
||||
// Both request responses arrived; the notification produced none.
|
||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
@ -485,12 +577,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
}
|
||||
|
||||
@ -545,13 +634,10 @@ mod tests {
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connect_loopback(&endpoint),
|
||||
)
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
|
||||
tokio::time::timeout(
|
||||
@ -565,8 +651,7 @@ mod tests {
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value =
|
||||
serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
|
||||
@ -136,9 +136,11 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
/// 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()))
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
@ -209,7 +211,9 @@ mod tests {
|
||||
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");
|
||||
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());
|
||||
}
|
||||
@ -252,9 +256,9 @@ mod tests {
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::{AgentId, ProjectId, Project};
|
||||
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()),
|
||||
|
||||
@ -5,31 +5,29 @@
|
||||
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
|
||||
//! are then exposed through `tauri::State<AppState>` to the command handlers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
|
||||
CloseTerminal,
|
||||
ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout,
|
||||
DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines,
|
||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
|
||||
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListTemplates,
|
||||
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
||||
RenameLayout, ResizeTerminal,
|
||||
ResolveMemoryLinks,
|
||||
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
|
||||
SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
|
||||
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
|
||||
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog,
|
||||
GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents,
|
||||
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory,
|
||||
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||
ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout,
|
||||
SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory,
|
||||
UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
||||
@ -37,19 +35,25 @@ use domain::ports::{
|
||||
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
|
||||
TemplateStore,
|
||||
};
|
||||
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
};
|
||||
use domain::remote::RemoteKind;
|
||||
use domain::{AgentId, DomainEvent, EmbedderProfile, Project, ProjectId};
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
|
||||
SystemClock, TokioBroadcastEventBus,
|
||||
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
use crate::chat::ChatBridge;
|
||||
@ -646,14 +650,15 @@ impl AppState {
|
||||
// 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>)
|
||||
.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>,
|
||||
),
|
||||
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
|
||||
as Arc<dyn application::ProviderSessionProvider>),
|
||||
);
|
||||
|
||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||
@ -821,8 +826,7 @@ impl AppState {
|
||||
// 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>;
|
||||
let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
|
||||
let input_mediator = Arc::new(
|
||||
MediatedInbox::with_pty(
|
||||
Arc::clone(&inmemory_mailbox),
|
||||
@ -835,8 +839,8 @@ impl AppState {
|
||||
) as Arc<dyn domain::input::InputMediator>;
|
||||
// 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>;
|
||||
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let orchestrator_service = Arc::new(
|
||||
OrchestratorService::new(
|
||||
Arc::clone(&create_agent),
|
||||
@ -1056,6 +1060,16 @@ impl AppState {
|
||||
.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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -1078,6 +1092,556 @@ impl AppState {
|
||||
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;
|
||||
};
|
||||
if !is_claude_mcp_profile(profile) {
|
||||
continue;
|
||||
}
|
||||
let _ = migrate_claude_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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
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 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 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
|
||||
@ -1113,10 +1677,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = endpoint
|
||||
.as_cli_arg()
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.ok()?;
|
||||
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.
|
||||
@ -1808,7 +2369,10 @@ mod mcp_serve_peer_tests {
|
||||
.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
|
||||
.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))
|
||||
@ -1833,7 +2397,10 @@ mod mcp_serve_peer_tests {
|
||||
"idea_memory_read",
|
||||
"idea_memory_write",
|
||||
] {
|
||||
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
"missing tool {expected}; got {names:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
@ -1888,7 +2455,11 @@ mod mcp_serve_peer_tests {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
|
||||
.collect();
|
||||
assert_eq!(processed.len(), 1, "exactly one processed event; got {events:?}");
|
||||
assert_eq!(
|
||||
processed.len(),
|
||||
1,
|
||||
"exactly one processed event; got {events:?}"
|
||||
);
|
||||
match processed[0] {
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id,
|
||||
@ -1976,7 +2547,11 @@ mod mcp_serve_peer_tests {
|
||||
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_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}"
|
||||
@ -2053,7 +2628,10 @@ mod mcp_serve_peer_tests {
|
||||
.write_all(handshake_line("", "agent-1").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client.write_all(tools_list_line(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)
|
||||
@ -2091,7 +2669,10 @@ mod mcp_serve_peer_tests {
|
||||
.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
|
||||
.write_all(tools_list_line(2).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
client_b.flush().await.unwrap();
|
||||
|
||||
let resp = read_one_response(&mut client_b)
|
||||
@ -2304,7 +2885,7 @@ mod mcp_e2e_loopback_tests {
|
||||
use async_trait::async_trait;
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use tokio::io::{AsyncWriteExt, BufReader, AsyncBufReadExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
@ -2683,8 +3264,8 @@ mod mcp_e2e_loopback_tests {
|
||||
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 conversations = Arc::new(InMemoryConversationRegistry::new())
|
||||
as Arc<dyn domain::conversation::ConversationRegistry>;
|
||||
let service = OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
@ -2743,12 +3324,8 @@ mod mcp_e2e_loopback_tests {
|
||||
if let Some(publish) = events {
|
||||
server = server.with_events(publish);
|
||||
}
|
||||
let handle = McpServerHandle::start(
|
||||
server,
|
||||
endpoint.clone(),
|
||||
listener,
|
||||
project_id_arg(project),
|
||||
);
|
||||
let handle =
|
||||
McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project));
|
||||
(handle, endpoint)
|
||||
}
|
||||
|
||||
@ -2790,7 +3367,7 @@ mod mcp_e2e_loopback_tests {
|
||||
{
|
||||
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(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
|
||||
@ -2859,7 +3436,11 @@ mod mcp_e2e_loopback_tests {
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, sessions, mailbox) = build_service(contexts);
|
||||
// Target already live in the PTY registry, so the ask reuses its terminal.
|
||||
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(4242)),
|
||||
);
|
||||
let proj = project();
|
||||
let (handle, endpoint) = start_real_server(service, &proj, None);
|
||||
|
||||
@ -2913,7 +3494,11 @@ mod mcp_e2e_loopback_tests {
|
||||
let reply_resp = read_one_response(&mut reader_b)
|
||||
.await
|
||||
.expect("a reply ack line");
|
||||
assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}");
|
||||
assert_eq!(
|
||||
reply_resp["result"]["isError"],
|
||||
json!(false),
|
||||
"got {reply_resp}"
|
||||
);
|
||||
|
||||
// The asker now receives its inline result.
|
||||
let resp = read_one_response(&mut reader_a)
|
||||
@ -2958,9 +3543,7 @@ mod mcp_e2e_loopback_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
write_half
|
||||
.write_all(
|
||||
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
|
||||
)
|
||||
.write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
write_half.flush().await.unwrap();
|
||||
@ -3031,20 +3614,23 @@ mod mcp_e2e_loopback_tests {
|
||||
.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.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}");
|
||||
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}");
|
||||
assert!(
|
||||
resp["result"].is_null(),
|
||||
"no result on parse error; got {resp}"
|
||||
);
|
||||
|
||||
// The server survived: a subsequent valid call still answers.
|
||||
write_half
|
||||
@ -3200,7 +3786,9 @@ mod bind_endpoint_d1_tests {
|
||||
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");
|
||||
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);
|
||||
@ -3214,7 +3802,10 @@ mod bind_endpoint_d1_tests {
|
||||
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
|
||||
drop(corpse);
|
||||
}
|
||||
assert!(path.exists(), "corpse socket inode remains (no live listener)");
|
||||
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.
|
||||
@ -3239,4 +3830,3 @@ mod bind_endpoint_d1_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user