//! `#[tauri::command]` handlers — the **driving adapters** (frontend → backend). //! //! Each handler is a thin shell: deserialise the DTO, call the use case from //! [`AppState`], map `Result` to `Result`. No business logic lives here. use tauri::ipc::Channel; use tauri::State; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, }; 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, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, 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, ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; use domain::{SkillRef, SkillScope}; /// `health` — trivial command validating the full IPC pipeline /// (frontend gateway → invoke → command → use case → ports → event relay). /// /// # Errors /// Returns an [`ErrorDto`] if the use case fails. #[tauri::command] pub fn health( request: Option, state: State<'_, AppState>, ) -> Result { let input = request.unwrap_or_default().into(); state .health .execute(input) .map(HealthResponseDto::from) .map_err(ErrorDto::from) } /// `create_project` — create a project from a root: init `.ideai/`, register it. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad root/name or a duplicate /// `(remote, root)`, `FILESYSTEM`/`STORE` on I/O failure). #[tauri::command] pub async fn create_project( request: CreateProjectRequestDto, state: State<'_, AppState>, ) -> Result { let output = state .create_project .execute(request.into()) .await .map_err(ErrorDto::from)?; // Start tailing this project's `.ideai/requests/` tree so an orchestrator // agent can delegate agent/skill creation to IdeA (§14.3). state.ensure_orchestrator_watch(&output.project); Ok(ProjectDto::from(output)) } /// `open_project` — load a project and its `.ideai/` meta/manifest. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on registry I/O failure). #[tauri::command] pub async fn open_project( project_id: String, state: State<'_, AppState>, ) -> Result { let id = parse_project_id(&project_id)?; let output = state .open_project .execute(OpenProjectInput { project_id: id }) .await .map_err(ErrorDto::from)?; // R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs // feuilles sur le même agent AVANT toute reprise (`list_resumable_agents` // relit la version persistée), de sorte qu'on ne propose / ne relance qu'une // session par agent. Idempotent (no-op sans doublon) et best-effort : un échec // ne doit pas bloquer l'ouverture. let _ = state .reconcile_layouts .execute(ReconcileLayoutsInput { project_id: id }) .await; // Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais // `close_project`, donc l'ouverture est le seul filet fiable pour repasser en // `idle` les agents `working`/`waiting`/`blocked` dont la session est morte. // Acte système (appelle le port live-state directement, jamais via la surface // MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne // AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW). let _ = state .reconcile_live_state .execute(ReconcileLiveStateInput { project_id: id }) .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)) } /// `close_project` — persist state and release resources for a project. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure). #[tauri::command] pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { let id = parse_project_id(&project_id)?; // T5: freeze `agent_was_running` on every agent leaf BEFORE any PTY release, // reading the live registry as it stands now. Best-effort: a snapshot failure // must not block the close. let _ = state .snapshot_running_agents .execute(SnapshotRunningAgentsInput { project_id: id }) .await; // Stop tailing this project's `.ideai/requests/` tree (§14.3). state.stop_orchestrator_watch(&id); state .close_project .execute(CloseProjectInput { project_id: id, // L2 has no UI-side workspace mutations to persist yet. workspace: None, }) .await .map(|_| ()) .map_err(ErrorDto::from) } /// `list_projects` — list the projects known to the registry. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on registry I/O failure). #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result { state .list_projects .execute() .await .map(ProjectListDto::from) .map_err(ErrorDto::from) } /// `read_project_context` — read `.ideai/CONTEXT.md` for a project. /// /// Missing context is returned as an empty string so a project whose `.ideai/` /// was deleted can still open cleanly. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `FILESYSTEM`/`STORE` on read or UTF-8 failure). #[tauri::command] pub async fn read_project_context( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .read_project_context .execute(ReadProjectContextInput { project }) .await .map(|out| out.content) .map_err(ErrorDto::from) } /// `update_project_context` — overwrite `.ideai/CONTEXT.md` for a project. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `FILESYSTEM` on write failure). #[tauri::command] pub async fn update_project_context( request: UpdateProjectContextRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; state .update_project_context .execute(UpdateProjectContextInput { project, content: request.content, }) .await .map_err(ErrorDto::from) } /// `get_project_permissions` — read `.ideai/permissions.json`. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id or store failure. #[tauri::command] pub async fn get_project_permissions( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .get_project_permissions .execute(application::GetProjectPermissionsInput { project }) .await .map(|out| ProjectPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `update_project_permissions` — replace project default permissions. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id or store failure. #[tauri::command] pub async fn update_project_permissions( request: UpdateProjectPermissionsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .update_project_permissions .execute(UpdateProjectPermissionsInput { project, permissions: request.permissions, }) .await .map(|out| ProjectPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `update_agent_permissions` — replace or remove one agent override. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store failure. #[tauri::command] pub async fn update_agent_permissions( request: UpdateAgentPermissionsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .update_agent_permissions .execute(UpdateAgentPermissionsInput { project, agent_id, permissions: request.permissions, }) .await .map(|out| ProjectPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `resolve_agent_permissions` — resolve project defaults plus agent override. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store failure. #[tauri::command] pub async fn resolve_agent_permissions( request: ResolveAgentPermissionsRequestDto, state: State<'_, AppState>, ) -> Result, ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .resolve_agent_permissions .execute(ResolveAgentPermissionsInput { project, agent_id }) .await .map(|out| out.effective.map(EffectivePermissionsDto)) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- /// `open_terminal` — spawn a PTY and wire its byte stream to the frontend. /// /// The frontend passes a per-session [`Channel`] (xterm's output sink). We: /// 1. run [`application::OpenTerminal`] (spawn the PTY, register the session), /// 2. register the channel in the [`PtyBridge`] keyed by the new session id, /// 3. start a pump that drains the PTY's blocking output stream and forwards /// each chunk through the bridge to that channel. /// /// Returns the [`TerminalSessionDto`] (its `sessionId` is what `write`/`resize`/ /// `close` reference). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad cwd/size, `PROCESS` if the PTY /// fails to spawn or its output cannot be subscribed). #[tauri::command] pub async fn open_terminal( request: OpenTerminalRequestDto, on_output: Channel, state: State<'_, AppState>, ) -> Result { let output = state .open_terminal .execute(request.into()) .await .map_err(ErrorDto::from)?; let session_id = output.session.id; // (2) Register the xterm output channel for this session. let gen = state.pty_bridge.register(session_id, on_output); // (3) Subscribe to the PTY's byte stream and pump it to the channel. The // stream is a blocking iterator, so it runs on a dedicated OS thread; it // ends when the PTY hits EOF (process exit) or this attach is superseded. let handle = PtyHandle { session_id }; match state.pty_port.subscribe_output(&handle) { Ok(stream) => { let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); std::thread::spawn(move || { for chunk in stream { if !bridge.send_output(&session_id, chunk) { break; } } // Stream ended: drop the channel only if still ours (a re-attach // may have superseded this generation — don't tear down its channel). bridge.unregister_if(&session_id, gen); }); } Err(e) => { state.pty_bridge.unregister(&session_id); return Err(ErrorDto::from(application::AppError::from(e))); } } Ok(TerminalSessionDto::from(output)) } /// `write_terminal` — forward bytes (xterm keystrokes) to a live PTY. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// session is unknown, `PROCESS` on PTY I/O failure). #[tauri::command] pub fn write_terminal( request: WriteTerminalRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = request.into_input()?; let session_id = input.session_id; let bytes = input.data.len(); let control = describe_terminal_write(&input.data); application::diag!( "[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}" ); match state.write_terminal.execute(input) { Ok(()) => { application::diag!( "[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}" ); Ok(()) } Err(e) => { application::diag!( "[pty-write] write_terminal failed: session={session_id} bytes={bytes} \ control={control} error={e}" ); Err(ErrorDto::from(e)) } } } fn describe_terminal_write(data: &[u8]) -> String { if data.is_empty() { return "".to_owned(); } if data.len() > 16 { return "".to_owned(); } data.iter() .map(|byte| match *byte { b'\r' => "\\r".to_owned(), b'\n' => "\\n".to_owned(), 0x7f => "\\x7f".to_owned(), byte if byte < 0x20 => format!("\\x{byte:02x}"), byte => char::from(byte).to_string(), }) .collect::>() .join("") } /// `resize_terminal` — resize a live PTY. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id/size, `NOT_FOUND` if the /// session is unknown, `PROCESS` on failure). #[tauri::command] pub fn resize_terminal( request: ResizeTerminalRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = request.into_input()?; state.resize_terminal.execute(input).map_err(ErrorDto::from) } /// `close_terminal` — kill a live PTY and tear down its channel. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// session is unknown, `PROCESS` if the kill fails). #[tauri::command] pub async fn close_terminal( session_id: String, state: State<'_, AppState>, ) -> Result { let input = parse_close_terminal(&session_id)?; let sid = parse_session_id(&session_id)?; let result = state .close_terminal .execute(input) .await .map(TerminalClosedDto::from) .map_err(ErrorDto::from); // Tear down the channel regardless of kill outcome. state.pty_bridge.unregister(&sid); result } /// `reattach_terminal` — re-bind a view to a **still-living** PTY without /// re-spawning it. /// /// Navigation (switching layout/tab) tears the xterm view down but must NOT kill /// the backend PTY (the AI keeps running). When the view comes back it calls this /// command, which: /// 1. reads the session's retained **scrollback** so the terminal can repaint, /// 2. registers the new per-session [`Channel`] in the [`PtyBridge`], /// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable /// broadcast), so new bytes flow to the new channel. /// /// Returns the scrollback bytes; the frontend writes them into xterm first, then /// receives subsequent output over `on_output`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS` /// if the session is no longer alive). #[tauri::command] pub fn reattach_terminal( session_id: String, on_output: Channel, state: State<'_, AppState>, ) -> Result { let sid = parse_session_id(&session_id)?; let handle = PtyHandle { session_id: sid }; // (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was // explicitly closed or exited) — surfaced as an error so the caller falls // back to opening a fresh terminal. let scrollback = state .pty_port .scrollback(&handle) .map_err(|e| ErrorDto::from(AppError::from(e)))?; // (2) Register the new output channel for this session, replacing any stale // one from a previous attach (and bumping the generation). let gen = state.pty_bridge.register(sid, on_output); // (3) Subscribe afresh to the live byte stream and pump it to the channel. // The fresh subscription supersedes the previous attach's, so its pump thread // ends and stops double-delivering this session's bytes. match state.pty_port.subscribe_output(&handle) { Ok(stream) => { let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); std::thread::spawn(move || { for chunk in stream { if !bridge.send_output(&sid, chunk) { break; } } bridge.unregister_if(&sid, gen); }); } Err(e) => { state.pty_bridge.unregister(&sid); return Err(ErrorDto::from(AppError::from(e))); } } Ok(ReattachResultDto { session_id, scrollback, }) } // --------------------------------------------------------------------------- // Layout (L4) // --------------------------------------------------------------------------- /// `load_layout` — read a project's named layout (the active one when `layout_id` /// is omitted). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project or layout is unknown, `STORE` on registry I/O failure). #[tauri::command] pub async fn load_layout( project_id: String, layout_id: Option, state: State<'_, AppState>, ) -> Result { let id = parse_project_id(&project_id)?; let lid = layout_id.as_deref().map(parse_layout_id).transpose()?; state .load_layout .execute(LoadLayoutInput { project_id: id, layout_id: lid, }) .await .map(LayoutDto::from) .map_err(ErrorDto::from) } /// `mutate_layout` — apply a split/merge/resize/move/setSession/setCellAgent /// operation, persist the result and announce `LayoutChanged`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id/operation or an invariant /// violation, `NOT_FOUND` for an unknown project/node, `FILESYSTEM`/`STORE` on I/O /// failure). #[tauri::command] pub async fn mutate_layout( project_id: String, layout_id: Option, operation: LayoutOperationDto, state: State<'_, AppState>, ) -> Result { let id = parse_project_id(&project_id)?; let lid = layout_id.as_deref().map(parse_layout_id).transpose()?; let operation = operation.into_operation()?; state .mutate_layout .execute(MutateLayoutInput { project_id: id, layout_id: lid, operation, }) .await .map(LayoutDto::from) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Named-layout management (#4) // --------------------------------------------------------------------------- /// `list_layouts` — list all named layouts of a project and the active one. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE`/`FILESYSTEM` on I/O failure). #[tauri::command] pub async fn list_layouts( project_id: String, state: State<'_, AppState>, ) -> Result { let id = parse_project_id(&project_id)?; state .list_layouts .execute(ListLayoutsInput { project_id: id }) .await .map(ListLayoutsDto::from) .map_err(ErrorDto::from) } /// `create_layout` — create a new empty named layout and make it active. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND` /// if the project is unknown, `STORE`/`FILESYSTEM` on I/O failure). #[tauri::command] pub async fn create_layout( request: CreateLayoutRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = parse_project_id(&request.project_id)?; let kind = request.parse_kind()?; state .create_layout .execute(CreateLayoutInput { project_id, name: request.name, kind, }) .await .map(CreateLayoutResultDto::from) .map_err(ErrorDto::from) } /// `rename_layout` — rename a named layout. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND` /// if the project or layout is unknown). #[tauri::command] pub async fn rename_layout( request: RenameLayoutRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project_id = parse_project_id(&request.project_id)?; let layout_id = parse_layout_id(&request.layout_id)?; state .rename_layout .execute(RenameLayoutInput { project_id, layout_id, name: request.name, }) .await .map_err(ErrorDto::from) } /// `delete_layout` — delete a named layout (cannot be the last one). /// /// Returns the active layout id after the deletion. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed id or last layout attempt, /// `NOT_FOUND` if the project or layout is unknown). #[tauri::command] pub async fn delete_layout( request: DeleteLayoutRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = parse_project_id(&request.project_id)?; let layout_id = parse_layout_id(&request.layout_id)?; state .delete_layout .execute(DeleteLayoutInput { project_id, layout_id, }) .await .map(DeleteLayoutResultDto::from) .map_err(ErrorDto::from) } /// `set_active_layout` — switch the active named layout of a project. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the /// project or layout is unknown). #[tauri::command] pub async fn set_active_layout( request: SetActiveLayoutRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project_id = parse_project_id(&request.project_id)?; let layout_id = parse_layout_id(&request.layout_id)?; state .set_active_layout .execute(SetActiveLayoutInput { project_id, layout_id, }) .await .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Profiles & first-run (L5) // --------------------------------------------------------------------------- /// `first_run_state` — whether the first-run wizard should show (no /// `profiles.json` yet) plus the pre-filled reference catalogue to seed it. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). #[tauri::command] pub async fn first_run_state(state: State<'_, AppState>) -> Result { state .first_run_state .execute() .await .map(FirstRunStateDto::from) .map_err(ErrorDto::from) } /// `reference_profiles` — the pre-filled, editable reference catalogue offered to /// agent creation/selection. /// /// Restricted to the **selectable** profiles (§17.3, lot D7): only profiles /// drivable in structured mode (today Claude + Codex) are returned. Gemini/Aider /// stay in the catalogue data but are not proposed, and there is no custom-profile /// entry. Persistence/editing of pre-existing (legacy) profiles is unaffected. /// /// # Errors /// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory). #[tauri::command] pub async fn reference_profiles(state: State<'_, AppState>) -> Result { state .reference_profiles .execute() .await .map(ProfileListDto::from) .map_err(ErrorDto::from) } /// `detect_profiles` — probe each candidate profile's detection command and /// report which CLIs are installed (✓/✗). /// /// # Errors /// Returns an [`ErrorDto`] (detection failures degrade to `available: false`). #[tauri::command] pub async fn detect_profiles( request: DetectProfilesRequestDto, state: State<'_, AppState>, ) -> Result { state .detect_profiles .execute(request.into()) .await .map(DetectProfilesResponseDto::from) .map_err(ErrorDto::from) } /// `list_profiles` — list the configured profiles. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). #[tauri::command] pub async fn list_profiles(state: State<'_, AppState>) -> Result { state .list_profiles .execute() .await .map(ProfileListDto::from) .map_err(ErrorDto::from) } /// `save_profile` — create or replace (by id) a single profile. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). #[tauri::command] pub async fn save_profile( request: SaveProfileRequestDto, state: State<'_, AppState>, ) -> Result { state .save_profile .execute(request.into()) .await .map(ProfileDto::from) .map_err(ErrorDto::from) } /// `delete_profile` — delete a profile by id. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if absent, /// `STORE` on failure). #[tauri::command] pub async fn delete_profile( profile_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = parse_delete_profile(&profile_id)?; state .delete_profile .execute(input) .await .map_err(ErrorDto::from) } /// `configure_profiles` — persist the batch of chosen/edited profiles, closing /// the first run. /// /// The selection surface offered upstream is already restricted to selectable /// profiles (§17.3, D7), so the wizard sends only structured-drivable profiles /// and no arbitrary custom command. This command itself stays permissive on /// purpose: it must keep persisting any profile shape so a project with a /// pre-existing Gemini/Aider/custom **legacy** profile remains editable and /// runnable (we restrict creation, not the existing). /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure). #[tauri::command] pub async fn configure_profiles( request: ConfigureProfilesRequestDto, state: State<'_, AppState>, ) -> Result { state .configure_profiles .execute(request.into()) .await .map(ProfileListDto::from) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Embedder profiles & engines (LOT C2 — §14.5.3) // --------------------------------------------------------------------------- /// `list_embedder_profiles` — list the configured embedder profiles (empty when /// none configured ⇒ the default `none` posture). /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on `embedder.json` I/O failure). #[tauri::command] pub async fn list_embedder_profiles( state: State<'_, AppState>, ) -> Result { state .list_embedder_profiles .execute() .await .map(EmbedderProfileListDto::from) .map_err(ErrorDto::from) } /// `save_embedder_profile` — create or replace (by id) a single embedder profile. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for empty id/name or zero dimension, `STORE` /// on I/O failure). #[tauri::command] pub async fn save_embedder_profile( request: SaveEmbedderProfileRequestDto, state: State<'_, AppState>, ) -> Result { state .save_embedder_profile .execute(request.into()) .await .map(EmbedderProfileDto::from) .map_err(ErrorDto::from) } /// `delete_embedder_profile` — delete an embedder profile by id. /// /// # Errors /// Returns an [`ErrorDto`] (`NOT_FOUND` if absent, `STORE` on I/O failure). #[tauri::command] pub async fn delete_embedder_profile( embedder_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .delete_embedder_profile .execute(DeleteEmbedderProfileInput { id: embedder_id }) .await .map_err(ErrorDto::from) } /// `describe_embedder_engines` — describe the engines available to the /// "configure an embedder?" UI: the recommended ONNX catalogue, a best-effort /// snapshot of the local environment, and which strategies are compiled in. /// /// # Errors /// Returns an [`ErrorDto`] — in practice never (the environment probe is best-effort). #[tauri::command] pub async fn describe_embedder_engines( state: State<'_, AppState>, ) -> Result { state .describe_embedder_engines .execute() .await .map(EmbedderEnginesDto::from) .map_err(ErrorDto::from) } /// `dismiss_embedder_suggestion` — persist the user's response to the one-time /// embedder suggestion (LOT C3 — §14.5.5): `later` (re-proposable next session) or /// `never` (silenced for good). Resolves the project root from `project_id`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project /// is unknown, `STORE` on `.embedder-prompt.json` I/O failure). #[tauri::command] pub async fn dismiss_embedder_suggestion( request: DismissEmbedderSuggestionRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; state .dismiss_embedder_suggestion .execute(request.into_input(project.root)) .await .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Agents (L6) // --------------------------------------------------------------------------- /// Resolves a [`domain::Project`] by id, mapping `StoreError` → `AppError` → `ErrorDto`. async fn resolve_project( project_id: &str, state: &State<'_, AppState>, ) -> Result { let id = parse_project_id(project_id)?; state .project_store .load_project(id) .await .map_err(|e| ErrorDto::from(AppError::from(e))) } /// `create_agent` — create a new project agent from scratch. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad id/name, `NOT_FOUND` if the /// project is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn create_agent( request: CreateAgentRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let profile_id = parse_profile_id(&request.profile_id)?; state .create_agent .execute(CreateAgentInput { project, name: request.name, profile_id, initial_content: request.initial_content, }) .await .map(AgentDto::from) .map_err(ErrorDto::from) } /// `list_agents` — list the agents of a project. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn list_agents( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .list_agents .execute(ListAgentsInput { project }) .await .map(AgentListDto::from) .map_err(ErrorDto::from) } /// `get_project_work_state` — read-only live/busy state for manifest agents. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on manifest I/O failure). #[tauri::command] pub async fn get_project_work_state( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .get_project_work_state .execute(GetProjectWorkStateInput { project }) .await .map(ProjectWorkStateDto::from) .map_err(ErrorDto::from) } /// `read_conversation_page` — human, paginated read of a conversation's **full** /// transcript (lot LS6). Archive-aware (segments + active), text never truncated. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed project/conversation id, /// `NOT_FOUND` if the project is unknown, `STORE` on log I/O failure). #[tauri::command] pub async fn read_conversation_page( request: ReadConversationPageRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let conversation = uuid::Uuid::parse_str(&request.conversation_id) .map(domain::ConversationId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid conversation id: {}", request.conversation_id), })?; let cursor = request.cursor(); let limit = request.limit.unwrap_or(0); state .read_conversation_page .execute(ReadConversationPageInput { project_root: project.root, conversation, cursor, limit, }) .await .map(TurnPageDto::from) .map_err(ErrorDto::from) } /// `list_live_agents` — list every agent that currently owns a live session /// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can /// disable an agent already running in another cell (the "one live session per /// agent" invariant). /// /// Reads the **aggregator** [`LiveSessions::live_agents`], the single source of /// truth for liveness across both registries (PTY + structured). Reading only /// `terminal_sessions` here was blind to structured chat agents, so a live chat /// agent would not be disabled in the UI and could be relaunched elsewhere /// (cadrage v5 §3.3, Trou B). `LiveSessions` is built on the fly from the two /// `Arc` registries already held by [`AppState`]; the aggregation logic itself /// is not duplicated. /// /// `project_id` is accepted for API symmetry and future per-project scoping; the /// session registry is process-wide today, so the full live set is returned (a /// project's agent ids are disjoint from other projects' by construction, so the /// frontend can filter by the agents it knows). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed project id). #[tauri::command] pub fn list_live_agents( project_id: String, state: State<'_, AppState>, ) -> Result { // Validate the id shape for a consistent contract, even though the registry // is not project-scoped yet. let _ = parse_project_id(&project_id)?; let live = LiveSessions::new( std::sync::Arc::clone(&state.terminal_sessions), std::sync::Arc::clone(&state.structured_sessions), ); Ok(LiveAgentListDto::from_pairs(live.live_agents())) } /// `attach_live_agent` — rebind an already-running agent session to a visible /// layout cell without respawning the CLI process. /// /// This is the backend side of "a cell is a view": a closed cell can leave an /// agent running in the background, and opening the agent in a new cell updates /// the session's host node while preserving the PTY/session/scrollback. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project/agent/live session is unknown). #[tauri::command] pub async fn attach_live_agent( request: AttachLiveAgentRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let node_id = parse_node_id(&request.node_id)?; state .attach_live_agent .execute(AttachLiveAgentInput { project, agent_id, node_id, }) .map(AttachLiveAgentResponseDto::from) .map_err(ErrorDto::from) } /// `stop_live_agent` — tear down an already-running agent's live session by agent /// id, polymorphically (PTY kill or structured shutdown), without removing the /// agent, its tickets, conversation summary or handoff. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project/agent is unknown or the agent has no live session, `PROCESS` on a /// kill/shutdown failure). #[tauri::command] pub async fn stop_live_agent( request: StopLiveAgentRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; // Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est // droppé ⇒ polling stoppé) avant de démonter sa session. state.stop_turn_watch(agent_id); state .stop_live_agent .execute(StopLiveAgentInput { project, agent_id }) .await .map(StopLiveAgentResponseDto::from) .map_err(ErrorDto::from) } /// `read_agent_context` — read an agent's Markdown context. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project or agent is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn read_agent_context( project_id: String, agent_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let agent_id = parse_agent_id(&agent_id)?; state .read_agent_context .execute(ReadAgentContextInput { project, agent_id }) .await .map(ReadAgentContextResponseDto::from) .map_err(ErrorDto::from) } /// `update_agent_context` — overwrite an agent's Markdown context. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project or agent is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn update_agent_context( request: UpdateAgentContextRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .update_agent_context .execute(UpdateAgentContextInput { project, agent_id, content: request.content, }) .await .map_err(ErrorDto::from) } /// `delete_agent` — remove an agent from the project manifest. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project or agent is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn delete_agent( project_id: String, agent_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&project_id, &state).await?; let agent_id = parse_agent_id(&agent_id)?; state .delete_agent .execute(DeleteAgentInput { project, agent_id }) .await .map_err(ErrorDto::from) } /// `inspect_conversation` — best-effort enriched details (last topic + token /// indicator) for a resume popup (T7). /// /// Best-effort by contract: a missing/unsupported inspector or a missing /// transcript yields **empty details** (absent `lastTopic`/`tokenCount`), never /// an error. Only a genuine store failure (resolving the agent/profile) errors. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project, agent or profile is unknown, `STORE` on a manifest/profile failure). #[tauri::command] pub async fn inspect_conversation( request: InspectConversationRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .inspect_conversation .execute(InspectConversationInput { project, agent_id, conversation_id: request.conversation_id, }) .await .map(ConversationDetailsDto::from) .map_err(ErrorDto::from) } /// `launch_agent` — spawn an agent's CLI in a PTY and wire its byte stream to /// the frontend via a [`Channel`]. /// /// Mirrors `open_terminal`: execute the use case (spawn + register session), /// register the xterm channel in the [`PtyBridge`], then pump PTY output to /// that channel on a dedicated OS thread. /// /// Returns the [`TerminalSessionDto`] (its `sessionId` is what /// `write`/`resize`/`close` reference). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad id/size, `NOT_FOUND` if the /// project, agent or profile is unknown, `PROCESS` if the PTY fails to spawn). #[tauri::command] pub async fn launch_agent( request: LaunchAgentRequestDto, on_output: Channel, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; // The hosting cell drives the singleton-invariant guard. Parse it when the // frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an // already-live agent is refused). let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; // Compose the MCP runtime (M5d): inject the OS/runtime facts the application // layer cannot compute. The endpoint comes from the **single source of truth** // (`mcp_endpoint`), the same value `ensure_mcp_server` binds the listener on // (cadrage v5 §2). The `--project` is the hyphen-free 32-hex `simple` form the // M5c handshake guard (`serve_peer`) compares against; the `--requester` is the // launching agent's id. A missing executable path (should not happen) degrades // to no runtime ⇒ apply_mcp_config writes the minimal declaration. // L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`) // pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable. let mcp_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(), }); // 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; // Session-limit resume context (LS7, §21.5): record the Project + cell size keyed by // agent so an auto-resume (which only carries agent/node/conversation) can recompose a // full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below. let resume_project = project.clone(); // Lot LS6 — project root captured before `project` moves, for the off-hot-path log // rotation triggered at thread (re)open below. let rotation_root = project.root.clone(); // Backstop no-reply — project root captured before `project` moves, to arm the // end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`). let watch_root = project.root.clone(); let output = state .launch_agent .execute(LaunchAgentInput { project, agent_id, rows: request.rows, cols: request.cols, node_id, // Resume id is a property of the hosting cell; the frontend passes the // leaf's current conversation id here. conversation_id: request.conversation_id.clone(), mcp_runtime, }) .await .map_err(ErrorDto::from)?; if let Ok(mut contexts) = state.resume_contexts.lock() { contexts.insert( agent_id, crate::state::ResumeContext { project: resume_project, rows: request.rows, cols: request.cols, }, ); } // Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si // son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement // effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle // posé au 1er lancement persiste. cwd = run dir isolé de l'agent. if let Some(profile) = output.profile.as_ref() { state.arm_turn_watch( &watch_root, agent_id, profile, output.assigned_conversation_id.clone(), ); } // Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et // **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au // launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un // `append` ne déclenche JAMAIS la rotation. Skippée si la cellule ne porte pas une // `conversation_id` UUID (rien à roter). if let Some(conversation) = request .conversation_id .as_deref() .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) .map(domain::ConversationId::from_uuid) { let rotate = std::sync::Arc::clone(&state.rotate_conversation_log); tokio::spawn(async move { let _ = rotate .execute(RotateConversationLogInput { project_root: rotation_root, conversation, }) .await; }); } let session_id = output.session.id; // Host cell of the freshly (re)launched session — the pivot the level-2 tap reports // to the service alongside the agent. let host_node_id = output.session.node_id; // §17.4/§17.6: a structured launch routes to an `AgentSession`, registered // **only** in `StructuredSessions` — its id is never a live PTY. Such a cell is // a chat view driven by `agent_send`/`reattach_agent_chat`, not by xterm bytes, // so we must NOT wire it to the PTY bridge. Doing so would call // `subscribe_output` with an id the PTY adapter has never seen → `NotFound` // ("process error: pty handle not found"), failing every structured agent // launch. Only the raw-PTY path (`structured: None`) gets the byte pump. if output.structured.is_none() { // Register the xterm output channel for this session. let gen = state.pty_bridge.register(session_id, on_output); // Level-2 session-limit detector (§21, niveau 2 — PTY/TUI sans adapter // structuré). Selection rule (§21.10-4) is the single infra source `applies`: // arm a parser **only** for a profile without a structured adapter that declares // a `rate_limit_pattern` (anti-double-détection avec le niveau 1). A `None` // profile (reattach/idempotent) or an invalid regex ⇒ no parser, jamais de panique. let rate_limit_parser = output .profile .as_ref() .filter(|p| infrastructure::ratelimit::applies(p)) .and_then(|p| p.rate_limit_pattern.as_ref()) .and_then(infrastructure::RateLimitParser::new); // Subscribe to the PTY's byte stream and pump it to the channel. // The stream is a blocking iterator; it runs on a dedicated OS thread and // ends when the PTY hits EOF or this attach is superseded. let handle = PtyHandle { session_id }; match state.pty_port.subscribe_output(&handle) { Ok(stream) => { let bridge: std::sync::Arc = std::sync::Arc::clone(&state.pty_bridge); // Captured for the level-2 tap (moved into the pump thread). let service = std::sync::Arc::clone(&state.session_limit_service); let detect_clock = std::sync::Arc::new(infrastructure::SystemClock::new()); let conversation_id = request.conversation_id.clone(); std::thread::spawn(move || { for chunk in stream { // §21.10-4 tap (best-effort par fragment) : avant de consommer le // fragment, on cherche le motif de limite. Une détection arme la // reprise via le service (détecter→planifier). Dormant si pas de // parser. La fragmentation PTV (motif coupé entre deux fragments) // est un raté best-effort connu pour LS7. if let Some(parser) = &rate_limit_parser { let text = String::from_utf8_lossy(&chunk); if let Some(limit) = parser .detect(&text, domain::ports::Clock::now_millis(&*detect_clock)) { service.on_rate_limited( agent_id, host_node_id, conversation_id.clone(), limit.resets_at_ms, ); } } if !bridge.send_output(&session_id, chunk) { break; } } bridge.unregister_if(&session_id, gen); }); } Err(e) => { state.pty_bridge.unregister(&session_id); return Err(ErrorDto::from(AppError::from(e))); } } } Ok(TerminalSessionDto::from(output)) } /// `change_agent_profile` — hot-swap an agent's runtime profile (§15.1). /// /// Mutates the profile in the manifest, clears the now-foreign conversation id on /// every persisted layout cell hosting the agent, and — if the agent is live — /// kills its PTY and relaunches the session in the same cell with the new engine. /// Announces [`DomainEvent::AgentProfileChanged`]. /// /// Returns the mutated [`AgentDto`] and the relaunched [`TerminalSessionDto`] when /// a live session was hot-swapped (absent otherwise). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project, agent or target profile is unknown, `STORE`/`FILESYSTEM`/`PROCESS` on /// the respective port failures). #[tauri::command] pub async fn change_agent_profile( request: ChangeAgentProfileRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let profile_id = parse_profile_id(&request.profile_id)?; state .change_agent_profile .execute(ChangeAgentProfileInput { project, agent_id, profile_id, rows: request.rows, cols: request.cols, }) .await .map(ChangeAgentProfileDto::from) .map_err(ErrorDto::from) } /// `agent_send` — send a prompt to a live **structured** (chat) session and pump /// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7). /// /// Twin of the PTY output pump: resolve the live [`AgentSession`] from the /// structured registry, open the turn stream (`session.send`), then drain that /// blocking [`ReplyStream`] on a dedicated OS thread, mapping each /// [`ReplyEvent`](domain::ports::ReplyEvent) to a [`ReplyChunk`] and forwarding it /// through the [`ChatBridge`]. The turn ends deterministically at /// [`ReplyChunk::Final`]; the stream is bounded and closes right after. /// /// The channel is (re-)registered each call, bumping the bridge **generation** so /// a previous attach's pump can no longer deliver (generation supersede): only the /// current generation's channel receives chunks, never a double emission. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live /// structured session owns the id, `PROCESS` if the turn fails to start). #[tauri::command] pub async fn agent_send( session_id: String, prompt: String, on_reply: Channel, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let sid = parse_session_id(&session_id)?; // Resolve the live structured session (the registry is the single source of // truth for liveness — no adapter is constructed here, §17.8 rule D). let session = state .structured_sessions .session(&sid) .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?; // (Re-)register the reply channel; bumps the generation so an earlier attach's // pump (if any) is superseded and stops delivering to its stale channel. let gen = state.chat_bridge.register(sid, on_reply); // Open the turn stream. A start failure leaves the just-registered channel in // place (the cell stays attached, ready for a retry) — mirrors the PTY pump, // which only unregisters on a hard subscribe failure; here the session is // still live, so we keep the attach and surface the error. let stream = session .send(&prompt) .await .map_err(|e| ErrorDto::from(AppError::from(e)))?; // Drain the blocking reply iterator on a dedicated OS thread (the stream is a // synchronous `Iterator`, exactly like the PTY byte stream). It runs to the // `Final` event (or stream end / superseded channel), then detaches *only its // own* generation so a concurrent re-attach is never torn down. let bridge = std::sync::Arc::clone(&state.chat_bridge); // Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent + host // cell of this session once; a `RateLimited` turn event then feeds the service // (détecter→planifier). DORMANT en composition B-2 (aucune session structurée // vivante) mais câblé pour forward-compat. `conversation_id` non disponible ici ⇒ // `None` (acceptable LS7). let service = std::sync::Arc::clone(&state.session_limit_service); let meta = state.structured_sessions.meta_for_session(&sid); std::thread::spawn(move || { for event in stream { // Non-terminal, sans contenu chat : un signal de limite alimente le service // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on // continue à drainer comme pour un battement. if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event { if let Some((agent_id, node_id)) = meta { service.on_rate_limited(agent_id, node_id, None, *resets_at_ms); } } // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire // chunk; skip them while still draining so the turn runs to its `Final`. let Some(chunk) = crate::chat::chunk_from_event(event) else { continue; }; // `send_output` always records into the conversation scrollback; the // boolean only reflects live delivery. If the view navigated away // (no channel at this generation) we keep draining so the turn still // completes and the scrollback stays whole for the next re-attach. let _ = bridge.send_output(&sid, chunk); } bridge.detach_if(&sid, gen); }); Ok(()) } /// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité /// (ARCHITECTURE §21.1-4, fenêtre annulable). /// /// Délègue à [`SessionLimitService::cancel_resume`](application::SessionLimitService::cancel_resume) : /// désarme le réveil et, **seulement si** l'annulation a réussi (le réveil n'avait pas /// encore tiré), publie `AgentResumeCancelled`. Renvoie `true` ssi une reprise a /// effectivement été annulée (`false` si aucune n'était armée, ou si elle venait de /// tirer — auquel cas la reprise suit son cours). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id). #[tauri::command] pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result { let id = parse_agent_id(&agent_id)?; Ok(state.session_limit_service.cancel_resume(id)) } /// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a /// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché /// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2. /// /// Le front ne dispose que de l'`agent_id` ; on résout côté backend : /// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry /// structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`] /// / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de /// cible ⇒ `NOT_FOUND`. /// - `conversation_id` : best-effort via la session structurée de l'agent /// ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé). /// /// Délègue ensuite à /// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume), /// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au /// front. Aucun nouvel événement. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live /// cell hosts the agent). #[tauri::command] pub async fn set_resume_at( agent_id: String, resets_at_ms: i64, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let id = parse_agent_id(&agent_id)?; // Résolution agent→cellule : la registry des sessions vivantes est la source de // vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`), // puis le terminal (PTY). let node_id = state .structured_sessions .node_for_agent(&id) .or_else(|| state.terminal_sessions.node_for_agent(&id)) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "aucune cellule vivante pour l'agent {id}" ))) })?; // `conversation_id` best-effort : seule une session structurée vivante l'expose. let conversation_id = state .structured_sessions .session_for_agent(&id) .and_then(|s| s.conversation_id()); state .session_limit_service .confirm_human_resume(id, node_id, conversation_id, resets_at_ms); Ok(()) } /// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2). /// /// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's /// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no /// ticket. Idempotent on an idle agent. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator, /// `NOT_FOUND` if the project or agent is unknown). #[tauri::command] pub async fn interrupt_agent( request: InterruptAgentRequestDto, 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 .interrupt_agent(&project, agent_id) .await .map(|_| ()) .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)?; application::diag!( "[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}", project.id ); state .orchestrator_service .note_delegation_delivered(&project, agent_id, ticket); Ok(()) } /// `set_front_attached` — the write-portal reports whether a **frontend terminal cell** /// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`). /// /// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the /// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by /// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so /// the task would otherwise be lost (a delegated agent that never receives — and never /// answers — its task). An agent **with** a cell keeps the frontend write-portal path. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id. #[tauri::command] pub async fn set_front_attached( request: FrontAttachedRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let agent_id = parse_agent_id(&request.agent_id)?; application::diag!( "[delivery] set_front_attached command: agent={agent_id} attached={}", request.attached ); state .orchestrator_service .set_agent_front_attached(agent_id, request.attached); 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). /// /// Navigation (switching layout/tab) tears the chat view down but must NOT kill /// the backend session (the AI keeps running in the registry). When the view comes /// back it calls this, which: /// 1. reads the session's retained **conversation scrollback** (the chunks already /// streamed) so the chat can repaint its prior turns, /// 2. registers the new per-session [`Channel`] in the [`ChatBridge`], bumping the /// generation so the previous attach's pump can no longer deliver. /// /// No new turn is started here (a turn is driven by `agent_send`); a pump only /// runs while a turn is in flight. Returns the scrollback; the frontend replays it /// then receives subsequent chunks over `on_reply`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live /// structured session owns the id — the caller then falls back to a fresh launch). #[tauri::command] pub fn reattach_agent_chat( session_id: String, on_reply: Channel, state: State<'_, AppState>, ) -> Result { let sid = parse_session_id(&session_id)?; // A missing live session means the conversation is gone (closed/never live) — // surfaced as NOT_FOUND so the caller falls back to opening a fresh cell. if state.structured_sessions.session(&sid).is_none() { return Err(ErrorDto::from(AppError::NotFound(format!( "structured session {sid}" )))); } // (1) Snapshot the conversation scrollback before swapping the channel. let scrollback = state.chat_bridge.scrollback(&sid); // (2) Register the new channel, superseding any previous attach (the bumped // generation makes the prior pump's `detach_if` a no-op, so it can't tear down // this live channel — generation supersede, no double emission). let _gen = state.chat_bridge.register(sid, on_reply); Ok(ReattachChatDto { session_id, scrollback, }) } /// `close_agent_session` — shut a live structured session down and tear its /// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7). /// /// Removes the session from the structured registry (so liveness checks no longer /// see it), `shutdown`s it polymorphically (kills the underlying process/SDK; /// idempotent by the port contract), and unregisters it from the [`ChatBridge`]. /// The chat twin of `close_terminal`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live /// structured session owns the id, `PROCESS` if the shutdown fails). #[tauri::command] pub async fn close_agent_session( session_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let sid = parse_session_id(&session_id)?; // Remove from the registry first so concurrent liveness checks stop seeing it; // we then own the only handle to shut down (outside the registry lock). let session = state .structured_sessions .remove(&sid) .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?; let result = session .shutdown() .await .map_err(|e| ErrorDto::from(AppError::from(e))); // Tear down the transport regardless of the shutdown outcome (the session is // already out of the registry; the cell is gone). state.chat_bridge.unregister(&sid); result } /// `list_resumable_agents` — read-only inventory of an open project's resumable /// agent cells (§15.2). Each entry carries the agent + its host cell, the CLI /// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag /// frozen at close, and whether the profile can resume a conversation. /// /// Best-effort by contract: an unreadable project/layout/manifest degrades to an /// empty list, never an error. Drives the reopen panel, which reuses /// `launch_agent` for the actual resume (no new resume command). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on a project-registry failure). #[tauri::command] pub async fn list_resumable_agents( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .list_resumable_agents .execute(ListResumableAgentsInput { project }) .await .map(ResumableAgentListDto::from) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Templates & sync (L7) // --------------------------------------------------------------------------- /// `create_template` — create a template in the global IDE store. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an empty name or malformed profile id, /// `STORE` on persistence failure). #[tauri::command] pub async fn create_template( request: CreateTemplateRequestDto, state: State<'_, AppState>, ) -> Result { let input = request.into_input()?; state .create_template .execute(input) .await .map(TemplateDto::from) .map_err(ErrorDto::from) } /// `update_template` — update a template's content (bumps version, fires /// `TemplateUpdated` event). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// template is unknown, `STORE` on persistence failure). #[tauri::command] pub async fn update_template( request: UpdateTemplateRequestDto, state: State<'_, AppState>, ) -> Result { let input = request.into_input()?; state .update_template .execute(input) .await .map(TemplateDto::from) .map_err(ErrorDto::from) } /// `list_templates` — list all templates in the global IDE store. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on persistence failure). #[tauri::command] pub async fn list_templates(state: State<'_, AppState>) -> Result { state .list_templates .execute() .await .map(TemplateListDto::from) .map_err(ErrorDto::from) } /// `delete_template` — remove a template from the global IDE store. /// /// Agents previously created from it keep their `.md`; drift detection simply /// finds nothing to compare against afterwards. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// template is unknown, `STORE` on failure). #[tauri::command] pub async fn delete_template( template_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let id = parse_template_id(&template_id)?; state .delete_template .execute(DeleteTemplateInput { template_id: id }) .await .map_err(ErrorDto::from) } /// `create_agent_from_template` — instantiate a project agent from a template. /// /// Copies the template's Markdown content, links the agent origin and version, /// and records the manifest entry. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project or template is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn create_agent_from_template( request: CreateAgentFromTemplateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let input = request.into_input(project)?; state .create_agent_from_template .execute(input) .await .map(|out| AgentDto(out.agent)) .map_err(ErrorDto::from) } /// `detect_agent_drift` — list which synchronized agents are behind their /// template (version drift). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn detect_agent_drift( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .detect_agent_drift .execute(DetectAgentDriftInput { project }) .await .map(AgentDriftListDto::from) .map_err(ErrorDto::from) } /// `sync_agent_with_template` — apply the latest template content to a /// synchronized agent. /// /// Returns whether a sync was applied and the resulting version. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project, agent or template is unknown, `STORE` on I/O failure). #[tauri::command] pub async fn sync_agent_with_template( request: SyncAgentWithTemplateRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .sync_agent_with_template .execute(SyncAgentWithTemplateInput { project, agent_id }) .await .map(SyncResultDto::from) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Git (L8) // --------------------------------------------------------------------------- /// `git_status` — report the working-tree status of a project's repository. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` if /// the repo is missing or the operation fails). #[tauri::command] pub async fn git_status( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_status .execute(GitStatusInput { root }) .await .map(GitStatusListDto::from) .map_err(ErrorDto::from) } /// `git_stage` — stage a path in a project's repository. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_stage( request: GitStageRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_stage .execute(GitStagePathInput { root, path: request.path, }) .await .map_err(ErrorDto::from) } /// `git_unstage` — unstage a path in a project's repository. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_unstage( request: GitStageRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_unstage .execute(GitStagePathInput { root, path: request.path, }) .await .map_err(ErrorDto::from) } /// `git_commit` — create a commit in a project's repository. /// /// Announces [`DomainEvent::GitStateChanged`]. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad id or an empty message, `GIT` /// on failure). #[tauri::command] pub async fn git_commit( request: GitCommitRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_commit .execute(GitCommitInput { project_id: project.id, root, message: request.message, }) .await .map(GitCommitDto::from) .map_err(ErrorDto::from) } /// `git_branches` — list branches and the current one for a project's repository. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_branches( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_branches .execute(GitBranchesInput { root }) .await .map(GitBranchesDto::from) .map_err(ErrorDto::from) } /// `git_checkout` — check out a branch in a project's repository. /// /// Announces [`DomainEvent::GitStateChanged`]. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_checkout( request: GitCheckoutRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_checkout .execute(GitCheckoutInput { project_id: project.id, root, branch: request.branch, }) .await .map_err(ErrorDto::from) } /// `git_log` — return the recent commit log for a project's repository. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_log( project_id: String, limit: usize, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_log .execute(GitLogInput { root, limit }) .await .map(GitCommitListDto::from) .map_err(ErrorDto::from) } /// `git_init` — initialise a git repository at a project's root. /// /// Announces [`DomainEvent::GitStateChanged`]. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_init(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_init .execute(GitInitInput { project_id: project.id, root, }) .await .map_err(ErrorDto::from) } /// `git_graph` — return the commit graph for all local branches of a project. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] pub async fn git_graph( project_id: String, limit: usize, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state .git_graph .execute(GitGraphInput { root, limit }) .await .map(GraphCommitListDto::from) .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Windows (L10) // --------------------------------------------------------------------------- use crate::dto::{parse_tab_id, MoveTabResultDto}; use application::MoveTabToNewWindowInput; /// `move_tab_to_new_window` — detach a tab into a brand-new OS window. /// /// Applies the workspace topology change (the tab is *moved*, not duplicated) /// and opens a fresh [`tauri::WebviewWindow`]. The session-state handoff to that /// window (rendering the detached tab) is the L11 multi-window UI work; this /// command provides the backend primitive and the new OS window. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed tab id, `NOT_FOUND` if the /// tab is unknown to the persisted workspace, `INTERNAL` if the window fails to /// open). #[tauri::command] pub async fn move_tab_to_new_window( app: tauri::AppHandle, tab_id: String, state: State<'_, AppState>, ) -> Result { let tid = parse_tab_id(&tab_id)?; let out = state .move_tab .execute(MoveTabToNewWindowInput { tab_id: tid }) .await .map_err(ErrorDto::from)?; let label = format!("win-{}", out.new_window_id); tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("index.html".into())) .title("IdeA") .inner_size(1280.0, 800.0) .build() .map_err(|e| ErrorDto { code: "INTERNAL".to_owned(), message: e.to_string(), })?; Ok(MoveTabResultDto::from(out)) } // --------------------------------------------------------------------------- // Skills (L12) // --------------------------------------------------------------------------- /// `create_skill` — create a skill in its scope's store. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed /// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure). #[tauri::command] pub async fn create_skill( request: CreateSkillRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .create_skill .execute(CreateSkillInput { name: request.name, // Description is set via the dedicated frontend field (T6); the create // path stays None for now so the affordance falls back to the body's // first line (see `Skill::effective_description`). description: None, content: request.content, scope: request.scope, project_root: project.root, }) .await .map(SkillDto::from) .map_err(ErrorDto::from) } /// `update_skill` — replace a skill's Markdown content. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content, /// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure). #[tauri::command] pub async fn update_skill( request: UpdateSkillRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let skill_id = parse_skill_id(&request.skill_id)?; state .update_skill .execute(UpdateSkillInput { scope: request.scope, skill_id, content: request.content, project_root: project.root, }) .await .map(SkillDto::from) .map_err(ErrorDto::from) } /// `list_skills` — list the skills in one scope. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on failure). #[tauri::command] pub async fn list_skills( project_id: String, scope: SkillScope, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .list_skills .execute(ListSkillsInput { scope, project_root: project.root, }) .await .map(SkillListDto::from) .map_err(ErrorDto::from) } /// `delete_skill` — remove a skill from its scope's store. /// /// Agents that referenced it keep their `SkillRef`; injection simply skips the /// now-absent skill. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project or skill is unknown, `STORE` on failure). #[tauri::command] pub async fn delete_skill( project_id: String, scope: SkillScope, skill_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&project_id, &state).await?; let id = parse_skill_id(&skill_id)?; state .delete_skill .execute(DeleteSkillInput { scope, skill_id: id, project_root: project.root, }) .await .map_err(ErrorDto::from) } /// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry. /// Idempotent. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project or agent is unknown, `STORE` on failure). #[tauri::command] pub async fn assign_skill_to_agent( request: AssignSkillRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let skill_id = parse_skill_id(&request.skill_id)?; state .assign_skill .execute(AssignSkillToAgentInput { project, agent_id, skill: SkillRef::new(skill_id, request.scope), }) .await .map_err(ErrorDto::from) } /// `unassign_skill_from_agent` — drop a skill assignment from an agent. /// Idempotent. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the /// project or agent is unknown, `STORE` on failure). #[tauri::command] pub async fn unassign_skill_from_agent( request: UnassignSkillRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let skill_id = parse_skill_id(&request.skill_id)?; state .unassign_skill .execute(UnassignSkillFromAgentInput { project, agent_id, skill_id, }) .await .map_err(ErrorDto::from) } // --------------------------------------------------------------------------- // Memory (LOT A — §14.5.1) // --------------------------------------------------------------------------- /// `create_memory` — create a memory note in the project's store. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields or /// malformed project id, `NOT_FOUND` if the project is unknown, `STORE` on /// failure). #[tauri::command] pub async fn create_memory( request: CreateMemoryRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .create_memory .execute(CreateMemoryInput { project_root: project.root, name: request.name, description: request.description, r#type: request.r#type, content: request.content, }) .await .map(MemoryDto::from) .map_err(ErrorDto::from) } /// `update_memory` — replace an existing memory note (re-validates invariants). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields, /// `NOT_FOUND` if the project is unknown, `STORE` on failure). #[tauri::command] pub async fn update_memory( request: UpdateMemoryRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let slug = parse_memory_slug(&request.slug)?; state .update_memory .execute(UpdateMemoryInput { project_root: project.root, slug, description: request.description, r#type: request.r#type, content: request.content, }) .await .map(MemoryDto::from) .map_err(ErrorDto::from) } /// `list_memories` — list the project's memory notes. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on failure). #[tauri::command] pub async fn list_memories( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .list_memories .execute(ListMemoriesInput { project_root: project.root, }) .await .map(MemoryListDto::from) .map_err(ErrorDto::from) } /// `get_memory` — read one memory note by slug. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the /// project or note is unknown, `STORE` on failure). #[tauri::command] pub async fn get_memory( project_id: String, slug: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let slug = parse_memory_slug(&slug)?; state .get_memory .execute(GetMemoryInput { project_root: project.root, slug, }) .await .map(MemoryDto::from) .map_err(ErrorDto::from) } /// `delete_memory` — remove a memory note (and its index row). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the /// project or note is unknown, `STORE` on failure). #[tauri::command] pub async fn delete_memory( project_id: String, slug: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let project = resolve_project(&project_id, &state).await?; let slug = parse_memory_slug(&slug)?; state .delete_memory .execute(DeleteMemoryInput { project_root: project.root, slug, }) .await .map_err(ErrorDto::from) } /// `read_memory_index` — read the structured `MEMORY.md` index. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on failure). #[tauri::command] pub async fn read_memory_index( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .read_memory_index .execute(ReadMemoryIndexInput { project_root: project.root, }) .await .map(MemoryIndexDto::from) .map_err(ErrorDto::from) } /// `recall_memory` — recall the most relevant memory entries for a query within /// a token budget (LOT B — §14.5.2). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the /// project is unknown, `STORE` on failure). An empty or absent memory and a zero /// budget both yield an empty list, not an error. #[tauri::command] pub async fn recall_memory( request: RecallMemoryRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .recall_memory .execute(RecallMemoryInput { project_root: project.root, text: request.text, token_budget: request.token_budget, }) .await .map(MemoryIndexDto::from) .map_err(ErrorDto::from) } /// `resolve_memory_links` — resolve a note's outgoing `[[slug]]` links. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the /// project or source note is unknown, `STORE` on failure). #[tauri::command] pub async fn resolve_memory_links( project_id: String, slug: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; let slug = parse_memory_slug(&slug)?; state .resolve_memory_links .execute(ResolveMemoryLinksInput { project_root: project.root, slug, }) .await .map(MemoryLinksDto::from) .map_err(ErrorDto::from) }