//! `#[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 serde::Serialize; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent}; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; use crate::dto::{ model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id, parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto, AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto, 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, ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::embedded_server::{ EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; use crate::stream::TauriChannelSink; use domain::{DeviceId, SkillRef, SkillScope}; use uuid::Uuid; /// `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) } /// `get_server_exposure_settings` — read persisted embedded-server exposure settings. /// /// # Errors /// Returns an [`ErrorDto`] when persisted settings are invalid. #[tauri::command] pub fn get_server_exposure_settings( state: State<'_, AppState>, ) -> Result { state.embedded_server.get_settings() } /// `save_server_exposure_settings` — validate and persist embedded-server exposure settings. /// /// # Errors /// Returns an [`ErrorDto`] when settings are invalid or cannot be written. #[tauri::command] pub fn save_server_exposure_settings( settings: ServerExposureSettingsDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state.embedded_server.save_settings(settings) } /// `preview_server_exposure_settings` — derive LAN candidates and upstream URL. /// /// # Errors /// Returns an [`ErrorDto`] when settings are invalid. #[tauri::command] pub fn preview_server_exposure_settings( settings: ServerExposureSettingsDto, state: State<'_, AppState>, ) -> Result { state.embedded_server.preview(settings) } /// `embedded_server_status` — return the current embedded server status. #[tauri::command] pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto { state.embedded_server.status() } /// `embedded_server_start` — start the embedded server with the shared backend core. /// /// # Errors /// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start. #[tauri::command] pub async fn embedded_server_start( state: State<'_, AppState>, ) -> Result { state.embedded_server.start(state.core()).await } /// `embedded_server_stop` — stop the embedded server. /// /// # Errors /// Returns an [`ErrorDto`] when stopping the listener fails. #[tauri::command] pub async fn embedded_server_stop( state: State<'_, AppState>, ) -> Result { state.embedded_server.stop().await } /// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code. /// /// # Errors /// Returns an [`ErrorDto`] when the embedded server is not running. #[tauri::command] pub fn embedded_server_generate_pairing_code( state: State<'_, AppState>, ) -> Result { state.embedded_server.generate_pairing_code() } /// Device row exposed to the desktop settings surface. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeviceDto { /// Device id. pub device_id: String, /// User-facing name. pub name: String, /// Pairing timestamp as epoch milliseconds. pub paired_at_ms: u64, /// Last successful access timestamp as epoch milliseconds. pub last_seen_at_ms: u64, /// Whether this is the current authenticated web device. pub is_current_device: bool, } /// List response for paired devices. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeviceListDto { /// Paired devices. pub devices: Vec, } /// Rename request for paired devices. #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameDeviceRequestDto { /// Device id. pub device_id: String, /// New name. pub name: String, } /// Revoke request for one paired device. #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RevokeDeviceRequestDto { /// Device id. pub device_id: String, } fn parse_device_id(raw: &str) -> Result { Uuid::parse_str(raw) .map(DeviceId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: "invalid device id".to_owned(), }) } /// `list_devices` — list paired devices through the desktop composition root. /// /// # Errors /// Returns an [`ErrorDto`] when the device store cannot be read. #[tauri::command] pub async fn list_devices(state: State<'_, AppState>) -> Result { let output = state .list_devices .execute(ListDevicesInput { current_device_id: None, }) .await .map_err(ErrorDto::from)?; Ok(DeviceListDto { devices: output .devices .into_iter() .map(|device| DeviceDto { device_id: device.device_id.to_string(), name: device.name, paired_at_ms: device.paired_at_ms, last_seen_at_ms: device.last_seen_at_ms, is_current_device: device.is_current_device, }) .collect(), }) } /// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state. /// /// # Errors /// Returns an [`ErrorDto`] when the embedded server is not running. #[tauri::command] pub fn create_pairing_code( state: State<'_, AppState>, ) -> Result { state.embedded_server.generate_pairing_code() } /// `rename_device` — rename a paired device. /// /// # Errors /// Returns an [`ErrorDto`] for invalid input or store failures. #[tauri::command] pub async fn rename_device( request: RenameDeviceRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .rename_device .execute(RenameDeviceInput { device_id: parse_device_id(&request.device_id)?, name: request.name, }) .await .map_err(ErrorDto::from) } /// `revoke_device` — revoke one paired device. /// /// # Errors /// Returns an [`ErrorDto`] for invalid input or store failures. #[tauri::command] pub async fn revoke_device( request: RevokeDeviceRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .revoke_device .execute(RevokeDeviceInput { device_id: parse_device_id(&request.device_id)?, }) .await .map_err(ErrorDto::from) } /// `revoke_all_devices` — revoke every paired device. /// /// # Errors /// Returns an [`ErrorDto`] when the device store cannot be rewritten. #[tauri::command] pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> { state .revoke_all_devices .execute() .await .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 { open_project_for_adapter(project_id, &state).await } /// Shared `open_project` adapter body used by desktop IPC and the HTTP server. /// /// Keeps the non-presentation side effects attached to project opening in one /// place while B3 adds a second driving adapter. pub(crate) async fn open_project_for_adapter( project_id: String, 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; // Réconcilie les tâches de fond persistées au même point de boot que le // live-state : les Running sans handle vivant deviennent terminales, les // complétions WakeOwner non livrées repassent dans l'inbox unifiée. let _ = state.reconcile_background_tasks.execute(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) } /// `get_project_system_permissions` — read `.ideai/system-permissions.json`. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id or store failure. #[tauri::command] pub async fn get_project_system_permissions( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .get_project_system_permissions .execute(GetProjectSystemPermissionsInput { project }) .await .map(|out| ProjectSystemPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `update_project_system_permissions` — replace project default system permissions. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id or store failure. #[tauri::command] pub async fn update_project_system_permissions( request: UpdateProjectSystemPermissionsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .update_project_system_permissions .execute(UpdateProjectSystemPermissionsInput { project, permissions: request.permissions, }) .await .map(|out| ProjectSystemPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `update_agent_system_permissions` — replace or remove one agent system override. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store failure. #[tauri::command] pub async fn update_agent_system_permissions( request: UpdateAgentSystemPermissionsRequestDto, 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_system_permissions .execute(UpdateAgentSystemPermissionsInput { project, agent_id, permissions: request.permissions, }) .await .map(|out| ProjectSystemPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `resolve_agent_system_permissions` — resolve wanted plus runtime-constrained system permissions. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store/probe failure. #[tauri::command] pub async fn resolve_agent_system_permissions( request: ResolveAgentSystemPermissionsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .resolve_agent_system_permissions .execute(ResolveAgentSystemPermissionsInput { project, agent_id }) .await .map(|out| ResolvedAgentSystemPermissionsDto(out.permissions)) .map_err(ErrorDto::from) } /// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id, invalid stored policy, or store failure. #[tauri::command] pub async fn get_mcp_tool_permissions( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .read_mcp_tool_permissions .execute(ReadMcpToolPermissionsInput { project }) .await .map(ProjectMcpToolPermissionsDto::from) .map_err(ErrorDto::from) } /// `update_project_mcp_tool_permissions` — replace or remove project MCP tool defaults. /// /// # Errors /// Returns an [`ErrorDto`] on invalid project id, invalid tool policy, or store failure. #[tauri::command] pub async fn update_project_mcp_tool_permissions( request: UpdateProjectMcpToolPermissionsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .update_project_mcp_tool_permissions .execute(UpdateProjectMcpToolPermissionsInput { project, policy: request.policy, }) .await .map(ProjectMcpToolPermissionsDto::from) .map_err(ErrorDto::from) } /// `update_agent_mcp_tool_permissions` — replace or remove one agent MCP tool override. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids, invalid tool policy, or store failure. #[tauri::command] pub async fn update_agent_mcp_tool_permissions( request: UpdateAgentMcpToolPermissionsRequestDto, 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_mcp_tool_permissions .execute(UpdateAgentMcpToolPermissionsInput { project, agent_id, policy: request.policy, }) .await .map(ProjectMcpToolPermissionsDto::from) .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. /// /// Self-healing: a stale `layout_id` does not error; the active layout is left /// unchanged and the actually-active id is returned (authoritative). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error. #[tauri::command] pub async fn set_active_layout( request: SetActiveLayoutRequestDto, state: State<'_, AppState>, ) -> Result { 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(SetActiveLayoutResultDto::from) .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) } /// `list_opencode_providers` — static catalogue of OpenCode cloud providers /// (ticket #92, lot B3). #[tauri::command] pub async fn list_opencode_providers( state: State<'_, AppState>, ) -> Result { Ok(state.list_opencode_providers.execute().into()) } /// `list_claude_models` — enriched Claude model catalogue. #[tauri::command] pub async fn list_claude_models( state: State<'_, AppState>, ) -> Result { Ok(state.list_claude_models.execute().await.into()) } /// `list_codex_models` — enriched Codex model catalogue. #[tauri::command] pub async fn list_codex_models( state: State<'_, AppState>, ) -> Result { Ok(state.list_codex_models.execute().await.into()) } /// `save_opencode_provider_profile` — create or replace an OpenCode profile /// backed by a cloud provider (ticket #92, lot B3). The literal API key is /// sealed into the `SecretStore`, never persisted in `profiles.json`. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an empty `providerId`/`model`, /// `STORE` on secret or profile persistence failure). #[tauri::command] pub async fn save_opencode_provider_profile( request: SaveOpenCodeProviderProfileRequestDto, state: State<'_, AppState>, ) -> Result { state .save_opencode_provider_profile .execute(request.into()) .await .map(ProfileDto::from) .map_err(ErrorDto::from) } /// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance /// from the canonical `opencode-llamacpp` seed/template. /// /// # Errors /// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a /// blank requested name). #[tauri::command] pub async fn clone_opencode_profile_from_seed( request: CloneOpenCodeProfileFromSeedRequestDto, state: State<'_, AppState>, ) -> Result { state .clone_opencode_profile_from_seed .execute(request.into()) .await .map(ProfileDto::from) .map_err(ErrorDto::from) } /// `clone_profile_from_seed` — create a new profile instance from a /// persisted/reference seed, with optional name/model overrides. /// /// # Errors /// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles /// I/O failure, `INVALID` for a blank requested name/model). #[tauri::command] pub async fn clone_profile_from_seed( request: CloneProfileFromSeedRequestDto, state: State<'_, AppState>, ) -> Result { state .clone_profile_from_seed .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) } /// `list_model_servers` — list configured local model servers. /// /// # Errors /// Returns an [`ErrorDto`] on registry failure. #[tauri::command] pub async fn list_model_servers( state: State<'_, AppState>, ) -> Result { state .list_model_servers .execute() .await .map(ModelServerConfigListDto::from) .map_err(ErrorDto::from) } /// `save_model_server` — upsert a local model server config. /// /// # Errors /// Returns an [`ErrorDto`] on registry failure. #[tauri::command] pub async fn save_model_server( request: SaveModelServerRequestDto, state: State<'_, AppState>, ) -> Result { let server_id = parse_model_server_id(&request.config.id)?; let existing = state .list_model_servers .execute() .await .map_err(ErrorDto::from)? .servers .into_iter() .map(|item| item.config) .find(|config| config.id == server_id); let input = save_model_server_input(request, existing.as_ref())?; state .save_model_server .execute(input) .await .map(ModelServerConfigDto::from) .map_err(ErrorDto::from) } /// `preview_model_server_command` — build the llama.cpp argv without persisting. /// /// # Errors /// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid. #[tauri::command] pub fn preview_model_server_command( config: ModelServerConfigDto, ) -> Result { let config = model_server_config_domain(config, None)?; let argv = infrastructure::LlamaCppRuntime::new() .build_argv(&config) .map_err(|err| ErrorDto { code: "INVALID".to_owned(), message: err.to_string(), })?; Ok(PreviewModelServerCommandDto { display: display_command(&argv.command, &argv.args), command: argv.command, args: argv.args, }) } /// `delete_model_server` — delete a local model server config when unused. /// /// # Errors /// Returns `model_server_in_use` if any OpenCode profile still references it. #[tauri::command] pub async fn delete_model_server( server_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let server_id = parse_model_server_id(&server_id)?; state .delete_model_server .execute(application::DeleteModelServerInput { server_id }) .await .map_err(model_server_command_error) } /// `delete_model_artifact` — delete a managed downloaded model artifact while /// keeping the local model-server config. /// /// # Errors /// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use` /// when a download or live agent blocks deletion, and model-server errors for /// cache I/O failures. #[tauri::command] pub async fn delete_model_artifact( server_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let server_id = parse_model_server_id(&server_id)?; state .delete_model_artifact .execute(application::DeleteModelArtifactInput { server_id }) .await .map_err(model_server_command_error) } fn model_server_command_error(err: AppError) -> ErrorDto { match err { AppError::ModelServer { code, message } => ErrorDto { code, message }, other => ErrorDto::from(other), } } fn display_command(command: &str, args: &[String]) -> String { std::iter::once(command) .chain(args.iter().map(String::as_str)) .map(shell_escape) .collect::>() .join(" ") } fn shell_escape(value: &str) -> String { if value.is_empty() { return "''".to_owned(); } if value .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '=')) { return value.to_owned(); } format!("'{}'", value.replace('\'', "'\\''")) } // --------------------------------------------------------------------------- // 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) } /// `get_app_exit_work_guard_state` — aggregate active work across all open projects. /// /// # Errors /// Returns an [`ErrorDto`] if an open project or its work-state read model cannot be read. #[tauri::command] pub async fn get_app_exit_work_guard_state( app: AppHandle, ) -> Result { crate::read_app_exit_work_guard_state(&app) .await .map(AppExitWorkGuardStateDto::from) .map_err(ErrorDto::from) } /// `confirm_app_exit` — bypass the close guard once and request main-window shutdown. /// /// # Errors /// Returns an [`ErrorDto`] if the main window cannot be closed programmatically. #[tauri::command] pub async fn confirm_app_exit(app: AppHandle) -> Result<(), ErrorDto> { crate::confirm_next_main_window_close(); if let Some(window) = app.get_webview_window("main") { window.close().map_err(|err| ErrorDto { code: "INTERNAL".to_owned(), message: format!("failed to close main window: {err}"), })?; } else { crate::shutdown_app_after_confirm(&app); app.exit(0); } Ok(()) } /// `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` scopes the process-wide runtime registry before serialization so two /// open projects carrying the same persisted `AgentId` cannot see each other's live /// session. /// /// # 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 project_id = 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_snapshots( live.live_agent_snapshots() .into_iter() .filter(|snapshot| snapshot.project_id == project_id) .collect(), )) } /// `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)?; let dependencies = state .orchestrator_service .active_wait_dependencies(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(project.id, agent_id); for dependency in dependencies { state.stop_turn_watch(project.id, dependency); } 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 project_id = project.id; 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, allow_structured_alongside_pty: false, }) .await .map_err(ErrorDto::from)?; if let Ok(mut contexts) = state.resume_contexts.lock() { contexts.insert( domain::RuntimeAgentKey::new(project_id, 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( project_id, &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( project_id, 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 and engine conversation once from the live registry; a `RateLimited` turn // event then feeds the service (détecter→planifier). Without a conversation id the // service must not pretend an automatic resume is armed. let service = std::sync::Arc::clone(&state.session_limit_service); let meta = state.structured_sessions.meta_for_session(&sid); std::thread::spawn(move || { let mut terminal_visible = false; 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((project_id, agent_id, node_id, conversation_id)) = &meta { let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) { (Some(conversation_id), resets_at_ms) => { (Some(conversation_id.clone()), *resets_at_ms) } (None, None) => (None, None), // Reset connu mais conversation moteur absente : ne pas armer // une reprise non reprenable ; surfacer le fallback humain. (None, Some(_)) => (None, None), }; service.on_rate_limited( *project_id, *agent_id, *node_id, conversation_id, 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; }; if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { terminal_visible = true; } // `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); } if !terminal_visible { let _ = bridge.send_output( &sid, ReplyChunk::Error { message: "Le modèle n'a renvoyé aucune réponse.".to_owned(), }, ); } 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→(projet, cellule) : la registry des sessions vivantes est la // source de vérité. Sans `project_id` dans le DTO historique, on exige un match // unique pour éviter d'armer la reprise d'un homonyme dans le mauvais projet. let live = LiveSessions::new( std::sync::Arc::clone(&state.terminal_sessions), std::sync::Arc::clone(&state.structured_sessions), ); let matches = live .live_agent_snapshots() .into_iter() .filter(|snapshot| snapshot.agent_id == id) .collect::>(); let snapshot = match matches.as_slice() { [snapshot] => snapshot, [] => { return Err(ErrorDto::from(AppError::NotFound(format!( "aucune cellule vivante pour l'agent {id}" )))); } _ => { return Err(ErrorDto::from(AppError::Invalid(format!( "plusieurs projets vivants portent l'agent {id}" )))); } }; let project_id = snapshot.project_id; let node_id = snapshot.node_id; // `conversation_id` best-effort : seule une session structurée vivante l'expose. let conversation_id = state .structured_sessions .session_for_agent_in_project(project_id, &id) .and_then(|s| s.conversation_id()); state.session_limit_service.confirm_human_resume( project_id, 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 ); let mut matches = Vec::new(); for project in state .project_store .list_projects() .await .map_err(|err| ErrorDto::from(application::AppError::Store(err.to_string())))? { if state .list_agents .execute(application::ListAgentsInput { project: project.clone(), }) .await .map(|out| out.agents.iter().any(|agent| agent.id == agent_id)) .unwrap_or(false) { matches.push(project); } } match matches.as_slice() { [project] => { state .orchestrator_service .set_agent_front_attached(project, agent_id, request.attached) } [] => application::diag!( "[delivery] set_front_attached ignored: agent={agent_id} not found in known projects" ), _ => application::diag!( "[delivery] set_front_attached ignored: ambiguous agent={agent_id} across {} projects", matches.len() ), } 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; /// Event emitted for detached view-window lifecycle changes. pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle"; /// Event emitted when the main-window focused project changes. pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed"; /// Response returned by `open_view_window`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OpenViewWindowResponseDto { /// Stable Tauri window label for this panel. pub label: String, /// App URL loaded by the panel-only window. pub url: String, /// Whether the command reused and focused an existing window. pub already_open: bool, } /// Response returned by `close_view_window`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CloseViewWindowResponseDto { /// Stable Tauri window label for this panel. pub label: String, /// `true` when a live window was found and close was requested. pub closed: bool, } /// Snapshot returned by `list_open_view_windows`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ViewWindowSnapshot { /// Panel id rendered by the detached window. pub panel: String, /// Stable Tauri window label. pub label: String, /// Whether Tauri currently reports the OS window as visible. pub visible: bool, } /// Payload emitted on `view-window://lifecycle`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ViewWindowLifecycleEventDto { /// Lifecycle kind: `"opened"`, `"focused"` or `"closed"`. pub kind: &'static str, /// Panel id rendered by the detached window. pub panel: String, /// Stable Tauri window label. pub label: String, } /// Payload emitted on `focused-project://changed`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FocusedProjectChangedEventDto { /// Focused project, or `null` when no project is focused. pub project: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ViewPanel { Projects, Context, Work, Tickets, Agents, Templates, Skills, Permissions, Memory, Git, } impl ViewPanel { pub(crate) fn parse(raw: &str) -> Result { match raw { "projects" => Ok(Self::Projects), "context" => Ok(Self::Context), "work" => Ok(Self::Work), "tickets" => Ok(Self::Tickets), "agents" => Ok(Self::Agents), "templates" => Ok(Self::Templates), "skills" => Ok(Self::Skills), "permissions" => Ok(Self::Permissions), "memory" => Ok(Self::Memory), "git" => Ok(Self::Git), _ => Err(ErrorDto { code: "INVALID".to_owned(), message: format!("invalid view panel: {raw}"), }), } } pub(crate) const fn as_str(self) -> &'static str { match self { Self::Projects => "projects", Self::Context => "context", Self::Work => "work", Self::Tickets => "tickets", Self::Agents => "agents", Self::Templates => "templates", Self::Skills => "skills", Self::Permissions => "permissions", Self::Memory => "memory", Self::Git => "git", } } pub(crate) const fn title(self) -> &'static str { match self { Self::Projects => "Projects", Self::Context => "Project context", Self::Work => "Work state", Self::Tickets => "Tickets", Self::Agents => "Agents", Self::Templates => "Templates", Self::Skills => "Skills", Self::Permissions => "Permissions", Self::Memory => "Memory", Self::Git => "Git", } } } pub(crate) fn view_window_label(panel: ViewPanel) -> String { format!("view-{}", panel.as_str()) } pub(crate) fn view_window_url(panel: ViewPanel) -> String { format!("index.html?panel={}", panel.as_str()) } fn view_panel_from_window_label(label: &str) -> Option { let rest = label.strip_prefix("view-")?; if let Ok(panel) = ViewPanel::parse(rest) { return Some(panel); } let (panel, project_id) = rest.rsplit_once('-')?; if project_id.len() != 32 || uuid::Uuid::parse_str(project_id).is_err() { return None; } ViewPanel::parse(panel).ok() } pub(crate) fn emit_view_window_lifecycle( app: &AppHandle, kind: &'static str, panel: ViewPanel, label: &str, ) { let _ = app.emit( VIEW_WINDOW_LIFECYCLE_EVENT, ViewWindowLifecycleEventDto { kind, panel: panel.as_str().to_owned(), label: label.to_owned(), }, ); } /// `set_focused_project` — update the backend focused-project state and notify panels. #[tauri::command] pub async fn set_focused_project( app: AppHandle, project: Option, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state.set_focused_project(project.clone()); app.emit( FOCUSED_PROJECT_CHANGED_EVENT, FocusedProjectChangedEventDto { project }, ) .map_err(internal_window_error) } /// `get_focused_project` — read the backend focused-project state. #[tauri::command] pub async fn get_focused_project( state: State<'_, AppState>, ) -> Result, ErrorDto> { Ok(state.get_focused_project()) } /// `list_open_view_windows` — return the detached panel windows currently /// registered by Tauri. #[tauri::command] pub fn list_open_view_windows(app: AppHandle) -> Vec { let mut windows = app .webview_windows() .into_iter() .filter_map(|(label, window)| { let panel = view_panel_from_window_label(&label)?; Some(ViewWindowSnapshot { panel: panel.as_str().to_owned(), label, visible: window.is_visible().unwrap_or(true), }) }) .collect::>(); windows.sort_by(|left, right| { left.panel .cmp(&right.panel) .then(left.label.cmp(&right.label)) }); windows } /// `open_view_window` — open or focus a detached OS window for one panel. /// /// The window is a normal decorated, resizable system window. Its webview loads /// `index.html?panel=` so the frontend can boot a panel-only shell that /// follows the backend focused-project state. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri /// fails to create/focus the window). #[tauri::command] pub async fn open_view_window( app: AppHandle, panel: String, ) -> Result { let panel = ViewPanel::parse(&panel)?; let label = view_window_label(panel); let url = view_window_url(panel); if let Some(window) = app.get_webview_window(&label) { window.show().map_err(internal_window_error)?; window.set_focus().map_err(internal_window_error)?; emit_view_window_lifecycle(&app, "focused", panel, &label); return Ok(OpenViewWindowResponseDto { label, url, already_open: true, }); } let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into())) .title(format!("IdeA - {}", panel.title())) .inner_size(1120.0, 760.0) .min_inner_size(720.0, 480.0) .resizable(true) .maximizable(true) .minimizable(true) .closable(true) .decorations(true) .build() .map_err(internal_window_error)?; let event_app = app.clone(); let event_label = label.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { .. } = event { emit_view_window_lifecycle(&event_app, "closed", panel, &event_label); } }); emit_view_window_lifecycle(&app, "opened", panel, &label); Ok(OpenViewWindowResponseDto { label, url, already_open: false, }) } /// `close_view_window` — request closing the detached OS window for one panel. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri /// fails to close the window). #[tauri::command] pub async fn close_view_window( app: AppHandle, panel: String, ) -> Result { let panel = ViewPanel::parse(&panel)?; let label = view_window_label(panel); let Some(window) = app.get_webview_window(&label) else { return Ok(CloseViewWindowResponseDto { label, closed: false, }); }; window.close().map_err(internal_window_error)?; Ok(CloseViewWindowResponseDto { label, closed: true, }) } fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto { ErrorDto { code: "INTERNAL".to_owned(), message: error.to_string(), } } #[cfg(test)] mod view_window_tests { use super::*; #[test] fn view_window_label_and_url_are_stable() { let panel = ViewPanel::Tickets; assert_eq!(view_window_label(panel), "view-tickets"); assert_eq!(view_window_url(panel), "index.html?panel=tickets"); } #[test] fn view_window_rejects_unknown_panel() { let err = ViewPanel::parse("terminal").unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("invalid view panel: terminal")); } #[test] fn view_window_label_parser_accepts_stable_and_legacy_labels() { assert_eq!( view_panel_from_window_label("view-tickets") .unwrap() .as_str(), "tickets" ); assert_eq!( view_panel_from_window_label("view-tickets-0000000000000000000000000000002a") .unwrap() .as_str(), "tickets" ); assert!(view_panel_from_window_label("main").is_none()); assert!(view_panel_from_window_label("view-unknown").is_none()); assert!(view_panel_from_window_label("view-tickets-not-a-project").is_none()); } #[test] fn view_window_snapshot_payload_is_camel_case() { let payload = ViewWindowSnapshot { panel: "tickets".to_owned(), label: "view-tickets".to_owned(), visible: true, }; let json = serde_json::to_string(&payload).unwrap(); assert!(json.contains("\"panel\":\"tickets\""), "json was {json}"); assert!( json.contains("\"label\":\"view-tickets\""), "json was {json}" ); assert!(json.contains("\"visible\":true"), "json was {json}"); } #[test] fn view_window_lifecycle_payload_is_camel_case() { let payload = ViewWindowLifecycleEventDto { kind: "closed", panel: "tickets".to_owned(), label: "view-tickets-7".to_owned(), }; let json = serde_json::to_string(&payload).unwrap(); assert!(!json.contains("projectId"), "json was {json}"); assert!(!json.contains("project_id"), "no snake_case leak: {json}"); } } /// `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) } // --------------------------------------------------------------------------- // Background tasks (B8 — first-class background command) // --------------------------------------------------------------------------- /// Maps a background-task port error to the frontend [`ErrorDto`] shape. fn background_error(err: domain::ports::BackgroundTaskPortError) -> ErrorDto { use domain::ports::BackgroundTaskPortError as E; let code = match &err { E::NotFound => "NOT_FOUND", E::AlreadyExists | E::Invalid(_) => "INVALID", E::Runner(_) => "PROCESS", E::Store(_) => "STORE", }; ErrorDto { code: code.to_owned(), message: err.to_string(), } } /// `spawn_background_command` — start a command-backed first-class background /// task whose completion is delivered to the owning agent's inbox. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id/path, `PROCESS` if the /// command cannot be spawned, `STORE` on persistence failure). #[tauri::command] pub async fn spawn_background_command( request: crate::dto::SpawnBackgroundCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = parse_project_id(&request.project_id)?; let owner_agent_id = parse_agent_id(&request.owner_agent_id)?; let cwd = domain::project::ProjectPath::new(request.cwd.clone()).map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid working directory: {}", request.cwd), })?; let command = domain::ports::SpawnSpec { command: request.command, args: request.args, cwd, env: request.env, context_plan: None, sandbox: None, }; let wake_policy = if request.record_only { domain::BackgroundTaskWakePolicy::RecordOnly } else { domain::BackgroundTaskWakePolicy::WakeOwner }; state .spawn_background_command .execute(application::SpawnBackgroundCommandInput { project_id, owner_agent_id, label: request.label, command, wake_policy, deadline_ms: request.deadline_ms, }) .await .map(|out| BackgroundTaskDto::from(out.task)) .map_err(ErrorDto::from) } /// `cancel_background_task` — request cancellation of a running background task. /// /// The terminal `Cancelled` state is written by the completion sink; this returns /// the task as currently persisted (absent if it no longer exists). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on /// failure). #[tauri::command] pub async fn cancel_background_task( task_id: String, state: State<'_, AppState>, ) -> Result, ErrorDto> { let id = parse_task_id(&task_id)?; state .cancel_background_task .execute(id) .await .map(|out| out.task.map(BackgroundTaskDto::from)) .map_err(ErrorDto::from) } /// `retry_background_task` — re-run a terminal command task under a **new** task /// id (the original id is never reused). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id or a non-retryable task, /// `NOT_FOUND` if the task is unknown, `PROCESS`/`STORE` on failure). #[tauri::command] pub async fn retry_background_task( task_id: String, state: State<'_, AppState>, ) -> Result { let id = parse_task_id(&task_id)?; state .retry_background_task .execute(id) .await .map(|out| BackgroundTaskDto::from(out.task)) .map_err(ErrorDto::from) } /// `attach_background_task` — attach a UI output subscriber to a command-backed /// background task. /// /// If the task is still live, the response contains PTY scrollback and a fresh /// live subscription is pumped to `on_output`. If it is already terminal, the /// response contains only the persisted output tail and no PTY subscription is /// attempted. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the task /// has no live handle and no terminal result, `PROCESS`/`STORE` on backend /// failure). #[tauri::command] pub async fn attach_background_task( task_id: String, on_output: Channel, state: State<'_, AppState>, ) -> Result { let id = parse_task_id(&task_id)?; let plan = state .background_task_attach_plan(id) .await .map_err(ErrorDto::from)?; let live = plan.live_handle.is_some(); if let Some(handle) = plan.live_handle { match state.pty_port.subscribe_output(&handle) { Ok(stream) => { std::thread::spawn(move || { let sink = TauriChannelSink::new(on_output); for chunk in stream { if sink.send(chunk).is_err() { break; } } }); } Err(e) => return Err(ErrorDto::from(AppError::from(e))), } } Ok(AttachBackgroundTaskResultDto { task_id: plan.task_id.to_string(), scrollback: plan.scrollback, live, }) } /// `list_background_tasks` — read the background-task read-model for a project, /// optionally narrowed to one owning agent. /// /// Returns the union of the agent's open tasks and undelivered completions, /// filtered to `projectId`. Without `agentId`, only the project's undelivered /// completions are returned (the store cannot enumerate open tasks project-wide). /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure). #[tauri::command] pub async fn list_background_tasks( project_id: String, agent_id: Option, state: State<'_, AppState>, ) -> Result, ErrorDto> { let project_id = parse_project_id(&project_id)?; let agent_id = match &agent_id { Some(raw) => Some(parse_agent_id(raw)?), None => None, }; let store = &state.background_task_store; let mut by_id: std::collections::HashMap = std::collections::HashMap::new(); if let Some(agent_id) = agent_id { for task in store .list_open_for_agent(agent_id) .await .map_err(background_error)? { by_id.insert(task.id, task); } } for task in store .list_undelivered_completions() .await .map_err(background_error)? { by_id.entry(task.id).or_insert(task); } let mut tasks: Vec = by_id .into_values() .filter(|task| task.project_id == project_id) .filter(|task| agent_id.map_or(true, |a| task.owner_agent_id == a)) .collect(); tasks.sort_by_key(|task| (task.created_at_ms, task.id)); Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect()) }