//! `#[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 base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; use base64::Engine; use serde::{Deserialize, Serialize}; use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent}; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::plugin_slash_commands_from_runtime_catalog; use application::{ AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput, ChatDraftScopeInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput, GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RestartCellAgentRuntimeInput, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; use domain::{LayoutNode, PersistedPluginLayoutWindow, PluginId, PluginLayoutType, PreferredView}; 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, AllocateManagedTempRequestDto, AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CancelAgentChatResponseDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, CustomConversationListDto, CustomConversationSummaryDto, CustomProjectIconAssetContentDto, CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListCustomConversationsRequestDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto, ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, RestartCellAgentRuntimeRequestDto, RestartCellAgentRuntimeResponseDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectOrchestratorRequestDto, UpdateProjectOrchestratorResponseDto, UpdateProjectPermissionsRequestDto, UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto, 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) } /// `list_slash_commands` — list/filter the unified slash-command registry. #[tauri::command] pub async fn list_slash_commands( request: Option, state: State<'_, AppState>, ) -> Result { let request = request.unwrap_or_default(); let project_id = request .project_id .as_deref() .map(parse_project_id) .transpose()?; let catalog = state .list_plugin_runtime_contributions .execute(project_id) .await .map_err(ErrorDto::from)?; let plugin_commands = plugin_slash_commands_from_runtime_catalog(&catalog).map_err(ErrorDto::from)?; Ok(state .list_slash_commands .execute_with_plugins( ListSlashCommandsInput { prefix: request.prefix, }, plugin_commands, ) .into()) } /// `execute_slash_command` — execute a selected slash command. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for unavailable commands/missing context, /// `NOT_FOUND` for unknown commands or closed sessions). #[tauri::command] pub async fn execute_slash_command( request: ExecuteSlashCommandRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = request .project_id .as_deref() .map(parse_project_id) .transpose()?; let session_id = request .session_id .as_deref() .map(parse_session_id) .transpose()?; let catalog = state .list_plugin_runtime_contributions .execute(project_id) .await .map_err(ErrorDto::from)?; let plugin_commands = plugin_slash_commands_from_runtime_catalog(&catalog).map_err(ErrorDto::from)?; let output = state .execute_slash_command .execute_with_plugins( ExecuteSlashCommandInput { name: request.name, session_id, arguments: request.arguments, }, plugin_commands, ) .map_err(ErrorDto::from)?; let effect = match output.effect { SlashCommandEffect::Help { commands } => SlashCommandEffectDto::Help { commands }, SlashCommandEffect::CleanConversation { session_id } => { let cleared_chunks = state.chat_bridge.clear_scrollback(&session_id); let (project_id, agent_id, node_id, _) = state .structured_sessions .meta_for_session(&session_id) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "structured session {session_id}" ))) })?; let runtime_key = domain::RuntimeAgentKey::new(project_id, agent_id); let context = state .resume_contexts .lock() .ok() .and_then(|contexts| contexts.get(&runtime_key).cloned()) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "launch context for structured session {session_id}" ))) })?; let removed = state .structured_sessions .remove_runtime(&session_id) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "structured session {session_id}" ))) })?; removed .session .shutdown() .await .map_err(|error| ErrorDto::from(AppError::Process(error.to_string())))?; if let Some(handle) = removed.managed_temp_handle { let _ = state .release_managed_temp .execute(application::ReleaseManagedTempInput { handle }) .await; } 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(), }); let relaunched = state .launch_agent .execute(LaunchAgentInput { project: context.project.clone(), agent_id, rows: context.rows, cols: context.cols, node_id: Some(node_id), conversation_id: None, mcp_runtime, allow_structured_alongside_pty: false, require_structured: true, terminal_interface: Some(domain::DefaultAgentTerminalInterface::IdeaCli), fresh_start: true, managed_temp_exposure: application::ManagedTempExposureRequest::default(), }) .await .map_err(ErrorDto::from)?; let replacement = relaunched .structured .as_ref() .ok_or_else(|| { ErrorDto::from(AppError::Process( "/clean did not relaunch a structured session".to_owned(), )) })? .session_id; state.chat_bridge.rebind_session(session_id, replacement); if let Ok(mut contexts) = state.resume_contexts.lock() { contexts.insert( runtime_key, crate::state::ResumeContext { conversation_id: relaunched.assigned_conversation_id.clone(), ..context }, ); } SlashCommandEffectDto::CleanConversation { session_id: replacement.to_string(), cleared_chunks, conversation_id: relaunched.assigned_conversation_id, } } SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch { session_id: session_id.to_string(), }, SlashCommandEffect::PluginCallback { plugin_id, command_id, session_id, arguments, } => SlashCommandEffectDto::PluginCallback { plugin_id, command_id, session_id: session_id.map(|id| id.to_string()), arguments, }, }; Ok(ExecuteSlashCommandResponseDto { command: output.command, effect, }) } /// `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)?; state .record_project_opened_for_restore .execute(output.project.clone()) .await .map_err(ErrorDto::from)?; state.activate_project(&output.project).await; 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_and_activate(id) .await .map_err(ErrorDto::from)?; Ok(ProjectDto::from(output)) } #[cfg(test)] mod open_project_template_tests { use std::path::PathBuf; use application::{ CreateAgentFromTemplateInput, CreateProjectInput, CreateTemplateInput, ReadTemplateInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentSystemPermissionsInput, }; use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet}; use uuid::Uuid; use super::*; fn temp_path(tag: &str) -> PathBuf { std::env::temp_dir().join(format!("idea-open-project-{tag}-{}", Uuid::new_v4())) } #[tokio::test] async fn open_project_does_not_backfill_agent_permission_overrides_into_templates() { let state = AppState::build(temp_path("appdata")); let project_root = temp_path("project"); let project = state .create_project .execute(CreateProjectInput { name: "No Template Backfill".to_owned(), root: project_root.to_string_lossy().into_owned(), remote: None, default_profile_id: None, }) .await .expect("project is created") .project; let profile_id = domain::ProfileId::from_uuid(Uuid::from_u128(44)); let template = state .create_template .execute(CreateTemplateInput { name: "Backend".to_owned(), content: "# template".to_owned(), default_profile_id: profile_id, default_agent_name: None, default_system_permissions: None, default_mcp_tool_policy: None, default_contactable: false, }) .await .expect("template is created") .template; let agent = state .create_agent_from_template .execute(CreateAgentFromTemplateInput { project: project.clone(), template_id: template.id, name: Some("Project Override".to_owned()), synchronized: true, }) .await .expect("agent is created from template") .agent; state .update_agent_system_permissions .execute(UpdateAgentSystemPermissionsInput { project: project.clone(), agent_id: agent.id, permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))), }) .await .expect("agent system permissions are overridden"); state .update_agent_mcp_tool_permissions .execute(UpdateAgentMcpToolPermissionsInput { project: project.clone(), agent_id: agent.id, policy: Some(McpToolPolicy { allowed_tools: vec!["idea_template_read".to_owned()], }), }) .await .expect("agent MCP tool permissions are overridden"); let before_open = state .read_template .execute(ReadTemplateInput { template_id: template.id, }) .await .expect("template can be read before open") .template; open_project_for_adapter(project.id.to_string(), &state) .await .expect("project opens"); let after_open = state .read_template .execute(ReadTemplateInput { template_id: template.id, }) .await .expect("template can be read after open") .template; assert_eq!(after_open, before_open); assert!(after_open.default_agent_name.is_none()); assert!(after_open.default_system_permissions.is_none()); assert!(after_open.default_mcp_tool_policy.is_none()); } #[tokio::test] async fn remove_project_adapter_returns_the_shared_contract() { let state = AppState::build(temp_path("remove-appdata")); let project_root = temp_path("remove-project"); let project = state .create_project .execute(CreateProjectInput { name: "Remove Adapter".to_owned(), root: project_root.to_string_lossy().into_owned(), remote: None, default_profile_id: None, }) .await .unwrap() .project; state .record_project_opened_for_restore .execute(project.clone()) .await .unwrap(); let response = remove_project_for_adapter( RemoveProjectRequestDto { project_id: project.id.to_string(), }, &state, ) .await .unwrap(); assert_eq!(response.removed.id, project.id.to_string()); assert!(response.projects.is_empty()); assert!(response.restore_state.entries.is_empty()); assert!(response.was_open); assert!(project_root.exists()); let _ = std::fs::remove_dir_all(project_root); } } /// `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_err(ErrorDto::from)?; state .record_project_closed_for_restore .execute(id) .await .map(|_| ()) .map_err(ErrorDto::from) } /// Removes one project from IdeA's registry without touching its root directory. #[tauri::command] pub async fn remove_project( request: RemoveProjectRequestDto, state: State<'_, AppState>, ) -> Result { remove_project_for_adapter(request, &state).await } /// Shared desktop/web adapter body for project removal. pub async fn remove_project_for_adapter( request: RemoveProjectRequestDto, state: &AppState, ) -> Result { let project_id = parse_project_id(&request.project_id)?; state .remove_project_and_deactivate(project_id) .await .map(Into::into) .map_err(ErrorDto::from) } /// Reads the ordered open-project restore state without opening projects. #[tauri::command] pub async fn read_open_project_restore_state( state: State<'_, AppState>, ) -> Result { state .read_open_project_restore_state .execute() .await .map(Into::into) .map_err(ErrorDto::from) } /// Restores all retained projects and applies normal-open runtime side effects. #[tauri::command] pub async fn restore_open_projects( state: State<'_, AppState>, ) -> Result { state .restore_open_projects_and_activate() .await .map(Into::into) .map_err(ErrorDto::from) } /// Retries one retained entry, or all entries carrying a restore failure. #[tauri::command] pub async fn retry_open_project_restore( request: RetryOpenProjectRestoreRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = request .project_id .as_deref() .map(parse_project_id) .transpose()?; state .retry_open_project_restore_and_activate(project_id) .await .map(Into::into) .map_err(ErrorDto::from) } /// Removes one retained entry from future restoration. #[tauri::command] pub async fn remove_project_from_restore( request: RemoveOpenProjectRestoreEntryRequestDto, state: State<'_, AppState>, ) -> Result { let project_id = parse_project_id(&request.project_id)?; state .remove_project_from_restore .execute(project_id) .await .map(Into::into) .map_err(ErrorDto::from) } /// Persists a new visible order; omitted retained entries keep their prior order. #[tauri::command] pub async fn save_open_project_restore_order( request: SaveOpenProjectRestoreOrderRequestDto, state: State<'_, AppState>, ) -> Result { let ordered_project_ids = request .ordered_project_ids .iter() .map(|id| parse_project_id(id)) .collect::, _>>()?; state .save_open_project_restore_order .execute(ordered_project_ids) .await .map(Into::into) .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) } /// Reads global machine-local IdeA preferences. #[tauri::command] pub async fn read_user_settings(state: State<'_, AppState>) -> Result { state .read_user_settings .execute() .await .map(Into::into) .map_err(Into::into) } /// Replaces global machine-local IdeA preferences. #[tauri::command] pub async fn update_user_settings( request: UserSettingsDto, state: State<'_, AppState>, ) -> Result { state .update_user_settings .execute(request.into()) .await .map(Into::into) .map_err(Into::into) } /// Reads project-owned settings without creating a legacy-missing file. #[tauri::command] pub async fn read_project_settings( project_id: String, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&project_id, &state).await?; state .read_project_settings .execute(&project) .await .map(Into::into) .map_err(Into::into) } /// Validates and updates project-owned settings. #[tauri::command] pub async fn update_project_settings( request: UpdateProjectSettingsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let theme_color = request .theme_color .map(|value| value.map(domain::ThemeColor::new).transpose()) .transpose() .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; let icon = request .icon .map(|value| value.map(domain::ProjectIconRef::try_from).transpose()) .transpose() .map_err(|message| ErrorDto::from(AppError::Invalid(message)))?; state .update_project_settings .execute(&project, theme_color, icon) .await .map(Into::into) .map_err(Into::into) } /// Imports a PNG, JPEG or WebP into the instance-local shared icon library. #[tauri::command] pub async fn import_custom_project_icon_asset( request: ImportCustomProjectIconAssetRequestDto, state: State<'_, AppState>, ) -> Result { let bytes = STANDARD .decode(request.data_base64.trim()) .map_err(|error| ErrorDto::invalid(format!("invalid base64 image bytes: {error}")))?; state .import_custom_project_icon_asset .execute(request.original_filename, request.media_type, bytes) .await .map(Into::into) .map_err(Into::into) } /// Lists the shared custom icon library. #[tauri::command] pub async fn list_custom_project_icon_assets( state: State<'_, AppState>, ) -> Result, ErrorDto> { state .list_custom_project_icon_assets .execute() .await .map(|assets| assets.into_iter().map(Into::into).collect()) .map_err(Into::into) } /// Reads one shared icon without exposing its filesystem path. #[tauri::command] pub async fn read_custom_project_icon_asset( asset_id: String, state: State<'_, AppState>, ) -> Result { state .read_custom_project_icon_asset .execute(&asset_id) .await .map(|output| CustomProjectIconAssetContentDto { metadata: output.metadata.into(), data_base64: STANDARD.encode(output.bytes), }) .map_err(Into::into) } /// Returns current registered-project usage for one shared icon. #[tauri::command] pub async fn get_custom_project_icon_asset_usage( asset_id: String, state: State<'_, AppState>, ) -> Result { state .get_custom_project_icon_asset_usage .execute(&asset_id) .await .map(Into::into) .map_err(Into::into) } /// Deletes one shared icon when it is unused. #[tauri::command] pub async fn delete_custom_project_icon_asset( asset_id: String, state: State<'_, AppState>, ) -> Result { state .delete_custom_project_icon_asset .execute(&asset_id) .await .map(Into::into) .map_err(Into::into) } /// `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) } /// `update_agent_effort` — set or clear one agent's per-agent effort override. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store failure. #[tauri::command] pub async fn update_agent_effort( request: UpdateAgentEffortRequestDto, 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_effort .execute(UpdateAgentEffortInput { project, agent_id, effort: request.effort, }) .await .map(|out| AgentDto::from_agent(out.agent)) .map_err(ErrorDto::from) } /// `update_agent_contactable` — enable or disable cross-project contact for one agent. /// /// # Errors /// Returns an [`ErrorDto`] on invalid ids or store failure. #[tauri::command] pub async fn update_agent_contactable( request: UpdateAgentContactableRequestDto, 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_contactable .execute(UpdateAgentContactableInput { project, agent_id, contactable: request.contactable, }) .await .map(|out| AgentDto::from_agent(out.agent)) .map_err(ErrorDto::from) } /// `update_project_orchestrator` — select the single global project-context writer. /// /// # Errors /// Returns an [`ErrorDto`] when ids are invalid, the agent is unknown, or persistence fails. #[tauri::command] pub async fn update_project_orchestrator( request: UpdateProjectOrchestratorRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; state .update_project_orchestrator .execute(UpdateProjectOrchestratorInput { project, agent_id }) .await .map(|out| UpdateProjectOrchestratorResponseDto { orchestrator_agent_id: out.orchestrator.to_string(), }) .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 { 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(ResolveAgentPermissionsResponseDto::from) .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) } /// Atomically adds or removes one native MCP tool from an agent override. #[tauri::command] pub async fn set_agent_mcp_tool_allowed( request: SetAgentMcpToolAllowedRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; state .set_agent_mcp_tool_allowed .execute(SetAgentMcpToolAllowedInput { project, agent_id: parse_agent_id(&request.agent_id)?, tool_name: request.tool_name, allowed: request.allowed, }) .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_with_scrollback(&handle) { Ok((scrollback, stream)) => { // The child can paint its initial screen before this command has // finished wiring the Tauri channel. Replay that retained prefix // before the live pump so a one-shot TUI draw is never lost. if !scrollback.is_empty() { let _ = state.pty_bridge.send_output(&session_id, scrollback); } 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) } /// Lists durable non-empty custom CLI conversations owned by an agent. #[tauri::command] pub async fn list_custom_agent_conversations( request: ListCustomConversationsRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let conversations = state .custom_conversations .list(&project.root, agent_id, request.limit.unwrap_or(0)) .await .map_err(ErrorDto::from)? .into_iter() .map(CustomConversationSummaryDto::from) .collect(); Ok(CustomConversationListDto { conversations }) } /// `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, reason: request.reason, }) .await .map(StopLiveAgentResponseDto::from) .map_err(ErrorDto::from) } /// Atomically restarts the runtime bound to an exact visible agent cell. /// /// The presentation hint is retained for correlation only. Runtime selection, /// stop ordering and relaunch are owned by the backend restart coordinator. #[tauri::command] pub async fn restart_cell_agent_runtime( request: RestartCellAgentRuntimeRequestDto, state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; let output = state .restart_cell_agent_runtime .execute(RestartCellAgentRuntimeInput { project, node_id: parse_node_id(&request.node_id)?, agent_id: parse_agent_id(&request.agent_id)?, presentation: request.presentation.into(), layout_id: request .layout_id .as_deref() .map(parse_layout_id) .transpose()?, })?; Ok(output.into()) } /// `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)?; let resumed_page = if request.cell_kind == Some(CellKind::Chat) { if let Some(raw) = request.conversation_id.as_deref() { let conversation_id = uuid::Uuid::parse_str(raw) .map(domain::ConversationId::from_uuid) .map_err(|_| { ErrorDto::from(AppError::Invalid(format!("invalid conversation id: {raw}"))) })?; state .custom_conversations .assert_owned(&project.root, agent_id, conversation_id) .await .map_err(ErrorDto::from)?; Some( state .read_conversation_page .execute(ReadConversationPageInput { project_root: project.root.clone(), conversation: conversation_id, cursor: domain::PageCursor { anchor: None, direction: domain::PageDirection::Backward, }, limit: 50, }) .await .map_err(ErrorDto::from)?, ) } else { None } } else { None }; // 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: project.clone(), 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, require_structured: request.cell_kind == Some(CellKind::Chat), terminal_interface: request.cell_kind.map(|kind| match kind { CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui, CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli, }), fresh_start: request.fresh_start, managed_temp_exposure: application::ManagedTempExposureRequest::default(), }) .await .map_err(ErrorDto::from)?; state .orchestrator_service .reactivate_agent_execution(&project, agent_id); 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, node_id, conversation_id: request .conversation_id .clone() .or_else(|| output.assigned_conversation_id.clone()), require_structured: request.cell_kind == Some(CellKind::Chat), }, ); } // 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_with_scrollback(&handle) { Ok((scrollback, stream)) => { // Launching a CLI performs substantial setup after the process // starts. Its first full-screen draw may therefore predate the // frontend channel; replay the atomic pre-attach snapshot first. if !scrollback.is_empty() { let _ = state.pty_bridge.send_output(&session_id, scrollback); } 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))); } } } let mut response = TerminalSessionDto::from(output); if let Some(page) = resumed_page { response.history = page .turns .into_iter() .filter(|turn| { matches!( turn.role, domain::TurnRole::Prompt | domain::TurnRole::Response ) }) .map(crate::dto::TurnViewDto::from) .collect(); response.history_has_more = page.has_more; response.history_next_anchor = page.next_anchor.map(|id| id.to_string()); response.resumed = true; } Ok(response) } /// `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) } fn chat_draft_scope_from_dto( request: ChatDraftScopeRequestDto, ) -> Result { Ok(ChatDraftScopeInput { project_id: parse_project_id(&request.project_id)?, layout_id: parse_layout_id(&request.layout_id)?, node_id: parse_node_id(&request.node_id)?, }) } /// `read_chat_draft` — read persisted custom chat draft text for a project/layout/node. /// /// # Errors /// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure. #[tauri::command] pub async fn read_chat_draft( request: ChatDraftScopeRequestDto, state: State<'_, AppState>, ) -> Result { let output = state .read_chat_draft .execute(chat_draft_scope_from_dto(request)?) .await .map_err(ErrorDto::from)?; Ok(ReadChatDraftResponseDto { text: output.text, updated_at_ms: output.updated_at_ms, }) } /// `save_chat_draft` — persist custom chat draft text for a project/layout/node. /// /// Empty text clears the draft. Attachments are not part of this contract. /// /// # Errors /// Returns an [`ErrorDto`] for malformed ids, unknown project, invalid text, or store failure. #[tauri::command] pub async fn save_chat_draft( request: SaveChatDraftRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .save_chat_draft .execute(SaveChatDraftInput { scope: ChatDraftScopeInput { project_id: parse_project_id(&request.project_id)?, layout_id: parse_layout_id(&request.layout_id)?, node_id: parse_node_id(&request.node_id)?, }, text: request.text, }) .await .map_err(ErrorDto::from) } /// `clear_chat_draft` — clear persisted custom chat draft text for a project/layout/node. /// /// # Errors /// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure. #[tauri::command] pub async fn clear_chat_draft( request: ChatDraftScopeRequestDto, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .clear_chat_draft .execute(chat_draft_scope_from_dto(request)?) .await .map_err(ErrorDto::from) } /// `managed_temp_allocate` — allocate an IdeA-managed temporary file/directory. /// /// # Errors /// Returns an [`ErrorDto`] for malformed ids, invalid TTL/purpose, or store failure. #[tauri::command] pub async fn managed_temp_allocate( request: AllocateManagedTempRequestDto, state: State<'_, AppState>, ) -> Result { let owner_project_id = request .owner_project_id .as_deref() .map(parse_project_id) .transpose()?; let output = state .allocate_managed_temp .execute(application::AllocateManagedTempInput { owner_project_id, kind: request.kind, purpose: request.purpose, ttl_ms: request.ttl_ms, }) .await .map_err(ErrorDto::from)?; Ok(ManagedTempLeaseResponseDto { lease: ManagedTempLeaseDto::from(output.lease), }) } /// `managed_temp_resolve` — resolve an active IdeA-managed temporary handle. /// /// # Errors /// Returns an [`ErrorDto`] for malformed handles, missing/released/expired handles, /// or store failure. #[tauri::command] pub async fn managed_temp_resolve( request: ManagedTempHandleRequestDto, state: State<'_, AppState>, ) -> Result { let output = state .resolve_managed_temp .execute(application::ResolveManagedTempInput { handle: request.handle, }) .await .map_err(ErrorDto::from)?; Ok(ManagedTempLeaseResponseDto { lease: ManagedTempLeaseDto::from(output.lease), }) } /// `managed_temp_release` — release one IdeA-managed temporary handle. /// /// # Errors /// Returns an [`ErrorDto`] for malformed handles, missing handles, or store failure. #[tauri::command] pub async fn managed_temp_release( request: ManagedTempHandleRequestDto, state: State<'_, AppState>, ) -> Result { let output = state .release_managed_temp .execute(application::ReleaseManagedTempInput { handle: request.handle, }) .await .map_err(ErrorDto::from)?; Ok(ManagedTempLeaseResponseDto { lease: ManagedTempLeaseDto::from(output.lease), }) } /// `managed_temp_gc` — delete released or expired managed temporary allocations. /// /// # Errors /// Returns an [`ErrorDto`] for store failure. #[tauri::command] pub async fn managed_temp_gc( state: State<'_, AppState>, ) -> Result { let output = state .gc_managed_temp .execute() .await .map_err(ErrorDto::from)?; Ok(ManagedTempGcResponseDto { deleted: output.report.deleted, }) } /// `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 logical turn ends deterministically at the /// authoritative [`ReplyChunk::Final`]. If the agent self-restarts, the old /// provider's terminal event is a control transition: the pump waits for the /// replacement session and only forwards its terminal outcome. /// /// 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, attachment_paths: Option>, attachments: Option>, 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}"))))?; let turn_generation = state .structured_sessions .begin_turn(&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); let imported_attachments = match import_chat_attachments_for_session( &state, &sid, attachment_paths.unwrap_or_default(), attachments.unwrap_or_default(), ) .await { Ok(attachments) => attachments, Err(error) => { state.structured_sessions.finish_turn(&sid, turn_generation); return Err(error); } }; let attachment_refs = chat_prompt_attachment_refs(&imported_attachments); let mut prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs); let attachment_labels = imported_attachments .iter() .map(|attachment| attachment.filename.clone()) .collect(); let session_meta = state.structured_sessions.meta_for_session(&sid); let custom_context = match ( session_meta.as_ref(), state.structured_sessions.custom_conversation_id(&sid), ) { (Some((project_id, agent_id, _, _)), Some(conversation_id)) => { let project = state .project_store .list_projects() .await .map_err(|error| ErrorDto::from(AppError::Store(error.to_string())))? .into_iter() .find(|project| project.id == *project_id) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!("project {project_id}"))) })?; Some((project.root, *agent_id, conversation_id)) } _ => None, }; if let Some((root, agent_id, conversation_id)) = &custom_context { let at_ms = u64::try_from(state.clock.now_millis()).unwrap_or_default(); if let Err(error) = state .custom_conversations .record_prompt(root, *agent_id, *conversation_id, at_ms, &prompt) .await { state.structured_sessions.finish_turn(&sid, turn_generation); return Err(ErrorDto::from(error)); } if let Some(summary) = state.structured_sessions.take_pending_resume_preamble(&sid) { prompt_for_model = format!( "Contexte borné de la conversation reprise :\n\n{summary}\n\n---\n\nNouveau message utilisateur :\n{prompt_for_model}" ); } } if let Some((project_id, agent_id, _, _)) = &session_meta { state.chat_bridge.bind_owner(sid, *project_id, *agent_id); } let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| { backend::StructuredHumanTurnGuard::start_with_source( std::sync::Arc::clone(&state.turn_watch_input), std::sync::Arc::clone(&state.input_mailbox), std::sync::Arc::clone(&state.restart_rendezvous), *project_id, *agent_id, &prompt, "agent_send", ) }); if let Some((agent, ticket)) = turn_guard .as_ref() .and_then(backend::StructuredHumanTurnGuard::accounting_identity) { let _ = state .structured_sessions .bind_turn_accounting(&sid, turn_generation, agent, ticket); } // Retain the human submit in the same live scrollback as model chunks so // `reattach_agent_chat` can repaint the whole in-flight conversation. let _ = state.chat_bridge.send_output( &sid, ReplyChunk::UserPrompt { at_ms: u64::try_from(state.clock.now_millis()).ok(), text: prompt.clone(), attachments: attachment_labels, attachment_refs, }, ); // Open the turn stream with a live progress tap. The returned `ReplyStream` // remains the authoritative drain to `Final`; the tap is best-effort // observability for provider-native/local progress produced while `send` is // still running. let (tap_tx, tap_rx) = std::sync::mpsc::channel(); let tap_bridge = std::sync::Arc::clone(&state.chat_bridge); let tap_clock = std::sync::Arc::clone(&state.clock); let tap_sid = sid; let tap_structured = std::sync::Arc::clone(&state.structured_sessions); let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle()); let tap_pump = std::thread::spawn(move || { let mut settler = crate::chat::ProgressSettler::default(); for event in tap_rx { if !tap_structured.accepts_turn_output(&tap_sid, turn_generation) { break; } if !matches!(event, domain::ports::ReplyEvent::Final { .. }) { if let Some(guard) = &tap_turn_guard { guard.mark_alive(); } } let Some(chunk) = crate::chat::chunk_from_event_at(event, u64::try_from(tap_clock.now_millis()).ok()) else { continue; }; if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { continue; } let _ = tap_bridge.send_output_settled(&tap_sid, &mut settler, chunk); } if tap_structured.accepts_turn_output(&tap_sid, turn_generation) { if let Some(completed) = settler.finish() { let _ = tap_bridge.send_output(&tap_sid, completed); } } }); if !state .structured_sessions .accepts_turn_output(&sid, turn_generation) { drop(tap_tx); let _ = tap_pump.join(); if let Some(guard) = &turn_guard { guard.finish(); } return Ok(()); } // 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_result = session.send_with_tap(&prompt_for_model, tap_tx).await; let _ = tap_pump.join(); let mut turn_guard = turn_guard; if !state .structured_sessions .accepts_turn_output(&sid, turn_generation) { if let Some(guard) = &turn_guard { guard.finish(); } state.chat_bridge.detach_if(&sid, gen); return Ok(()); } let stream = match stream_result { Ok(stream) => stream, Err(_error) if turn_guard .as_mut() .is_some_and(|guard| guard.restart_is_active()) => { // Teardown of the old provider is expected once self-restart owns the // logical turn. The detached drain below waits for the replacement. Box::new(std::iter::empty()) as domain::ports::ReplyStream } Err(error) => { state.structured_sessions.finish_turn(&sid, turn_generation); return Err(ErrorDto::from(AppError::from(error))); } }; // 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); let clock = std::sync::Arc::clone(&state.clock); // 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 structured = std::sync::Arc::clone(&state.structured_sessions); let runtime = tokio::runtime::Handle::current(); let meta = session_meta; let custom_conversations = std::sync::Arc::clone(&state.custom_conversations); std::thread::spawn(move || { let mut terminal_visible = false; let mut settler = crate::chat::ProgressSettler::default(); let mut current_stream = stream; 'logical_turn: loop { let mut concrete_terminal = false; let mut resume = false; let mut resume_prompt = None; for event in current_stream { if !structured.accepts_turn_output(&sid, turn_generation) { terminal_visible = true; break 'logical_turn; } let provider_terminal = crate::chat::reply_event_ends_turn(&event); if let domain::ports::ReplyEvent::Final { content } = &event { if let Some((root, agent_id, conversation_id)) = &custom_context { let _ = runtime.block_on(custom_conversations.record_response( root, *agent_id, *conversation_id, u64::try_from(clock.now_millis()).unwrap_or_default(), content, )); } } if provider_terminal { concrete_terminal = true; if let Some(guard) = turn_guard.as_mut() { match runtime.block_on(guard.transition_after_provider_terminal_async()) { backend::StructuredHumanTurnTransition::Complete => {} backend::StructuredHumanTurnTransition::Resume { prompt } => { resume = true; resume_prompt = Some(prompt); break; } backend::StructuredHumanTurnTransition::Failed(message) => { terminal_visible = true; let _ = bridge.send_output_settled( &sid, &mut settler, ReplyChunk::Error { message, at_ms: u64::try_from(clock.now_millis()).ok(), }, ); break 'logical_turn; } backend::StructuredHumanTurnTransition::Cancelled => { terminal_visible = true; break 'logical_turn; } } } } else { if let Some(guard) = &turn_guard { guard.mark_alive(); } } // 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_at(event, u64::try_from(clock.now_millis()).ok()) 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_settled(&sid, &mut settler, chunk); if provider_terminal { // Final/Error is the authoritative structured boundary. Some // providers keep their iterator/channel alive after publishing it; // waiting for EOF would leave the mediated ticket Busy forever even // though the UI already displayed the terminal chunk. break 'logical_turn; } } if !resume && !concrete_terminal { if let Some(guard) = turn_guard.as_mut() { match runtime.block_on(guard.transition_after_provider_terminal_async()) { backend::StructuredHumanTurnTransition::Complete => {} backend::StructuredHumanTurnTransition::Resume { prompt } => { resume = true; resume_prompt = Some(prompt); } backend::StructuredHumanTurnTransition::Failed(message) => { terminal_visible = true; let _ = bridge.send_output_settled( &sid, &mut settler, ReplyChunk::Error { message, at_ms: u64::try_from(clock.now_millis()).ok(), }, ); } backend::StructuredHumanTurnTransition::Cancelled => { terminal_visible = true; } } } } if !resume { break; } let Some((project_id, agent_id, _, _)) = meta.as_ref() else { break; }; let Some(replacement) = structured.session_for_agent_in_project(*project_id, agent_id) else { terminal_visible = true; let _ = bridge.send_output_settled( &sid, &mut settler, ReplyChunk::Error { message: "Le redémarrage n'a pas fourni de session de remplacement." .to_owned(), at_ms: u64::try_from(clock.now_millis()).ok(), }, ); break; }; // The provider replacement owns a fresh SessionId, but this remains // one logical human chat. Move the transport transcript/sink before // opening the continuation stream; writes still made with `sid` are // resolved by ChatBridge to the replacement id. bridge.rebind_session(sid, replacement.id()); let resume_prompt = resume_prompt .unwrap_or_else(|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned()); match runtime.block_on(replacement.send(&resume_prompt)) { Ok(stream) => current_stream = stream, Err(error) => { terminal_visible = true; let _ = bridge.send_output_settled( &sid, &mut settler, ReplyChunk::Error { message: error.to_string(), at_ms: u64::try_from(clock.now_millis()).ok(), }, ); break; } } } if !terminal_visible && structured.accepts_turn_output(&sid, turn_generation) { let chunk = crate::chat::with_chunk_timestamp( crate::chat::unfinished_turn_chunk(), u64::try_from(clock.now_millis()).ok(), ); let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } if let Some(guard) = &turn_guard { guard.finish(); } structured.finish_turn(&sid, turn_generation); bridge.detach_if(&sid, gen); }); Ok(()) } /// `import_chat_attachments` — imports user-supplied files into durable, /// sandbox-readable storage for a live structured chat session. /// /// This command exposes the same backend pipeline used by [`agent_send`] without /// starting a model turn, so frontend code can preflight/import attachments and /// render the durable metadata before submitting the prompt. /// /// # Errors /// Returns an [`ErrorDto`] for malformed ids, missing live session metadata, or /// invalid/unreadable source files. #[tauri::command] pub async fn import_chat_attachments( request: ImportChatAttachmentsRequestDto, state: State<'_, AppState>, ) -> Result { let sid = parse_session_id(&request.session_id)?; let attachments = import_chat_attachments_for_session( &state, &sid, request.attachment_paths, request.attachments, ) .await?; Ok(ImportChatAttachmentsResponseDto { attachments }) } async fn import_chat_attachments_for_session( state: &AppState, session_id: &domain::SessionId, legacy_paths: Vec, attachments: Vec, ) -> Result, ErrorDto> { if legacy_paths.is_empty() && attachments.is_empty() { return Ok(Vec::new()); } let (project_id, agent_id, _, _) = state .structured_sessions .meta_for_session(session_id) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "structured session {session_id}" ))) })?; let project = state .project_store .list_projects() .await .map_err(|err| ErrorDto::from(AppError::Store(err.to_string())))? .into_iter() .find(|project| project.id == project_id) .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?; let mut seen_paths = std::collections::HashSet::new(); let mut items = Vec::with_capacity(legacy_paths.len() + attachments.len()); for path in legacy_paths { if seen_paths.insert(path.clone()) { items.push(application::ImportChatAttachmentItem { source: application::ImportChatAttachmentSource::Path(path), mime: None, source_kind: domain::ChatAttachmentSourceKind::LocalFile, }); } } for attachment in attachments { let Some(item) = chat_attachment_input_to_item(attachment, &mut seen_paths)? else { continue; }; items.push(item); } let output = state .import_chat_attachments .execute(application::ImportChatAttachmentsInput { project, agent_id, session_id: *session_id, attachments: items, }) .await .map_err(ErrorDto::from)?; Ok(output .attachments .into_iter() .map(ChatAttachmentDto::from) .collect()) } fn chat_attachment_input_to_item( attachment: ChatAttachmentInputDto, seen_paths: &mut std::collections::HashSet, ) -> Result, ErrorDto> { let has_path = attachment .path .as_deref() .is_some_and(|path| !path.is_empty()); let has_content = attachment .content_base64 .as_deref() .is_some_and(|content| !content.is_empty()); if has_path && has_content { return Err(ErrorDto::from(AppError::Invalid( "chat attachment must provide either path or contentBase64, not both".to_owned(), ))); } if let Some(path) = attachment.path.filter(|path| !path.is_empty()) { if !seen_paths.insert(path.clone()) { return Ok(None); } return Ok(Some(application::ImportChatAttachmentItem { source: application::ImportChatAttachmentSource::Path(path), mime: attachment.mime, source_kind: attachment .source_kind .unwrap_or(domain::ChatAttachmentSourceKind::LocalFile), })); } if let Some(content_base64) = attachment .content_base64 .filter(|content| !content.is_empty()) { let filename = attachment.filename.ok_or_else(|| { ErrorDto::from(AppError::Invalid( "chat attachment bytes require filename".to_owned(), )) })?; let bytes = decode_attachment_content_base64(&content_base64)?; return Ok(Some(application::ImportChatAttachmentItem { source: application::ImportChatAttachmentSource::Bytes { filename, bytes }, mime: attachment.mime, source_kind: attachment .source_kind .unwrap_or(domain::ChatAttachmentSourceKind::Clipboard), })); } Err(ErrorDto::from(AppError::Invalid( "chat attachment must provide path or contentBase64".to_owned(), ))) } fn decode_attachment_content_base64(raw: &str) -> Result, ErrorDto> { STANDARD .decode(raw) .or_else(|_| STANDARD_NO_PAD.decode(raw)) .or_else(|_| URL_SAFE.decode(raw)) .or_else(|_| URL_SAFE_NO_PAD.decode(raw)) .map_err(|_| { ErrorDto::from(AppError::Invalid( "invalid chat attachment contentBase64".to_owned(), )) }) } fn prompt_with_imported_attachments( prompt: &str, attachments: &[ChatAttachmentDto], refs: &[ChatPromptAttachmentDto], ) -> String { if attachments.is_empty() { return prompt.to_owned(); } let mut out = String::with_capacity(prompt.len() + attachments.len() * 160); out.push_str(prompt); out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n"); for (attachment, attachment_ref) in attachments.iter().zip(refs.iter()) { out.push_str("- "); out.push_str(&attachment_ref.reference); out.push_str(" id: "); out.push_str(&attachment.id); out.push_str("; file: "); out.push_str(&attachment.filename); out.push_str("; mime: "); out.push_str(&attachment.mime); out.push_str("; bytes: "); out.push_str(&attachment.size_bytes.to_string()); out.push_str("; path: "); out.push_str(&attachment.readable_path); out.push('\n'); } out } fn chat_prompt_attachment_refs(attachments: &[ChatAttachmentDto]) -> Vec { let mut image_count = 0usize; let mut file_count = 0usize; attachments .iter() .map(|attachment| { let reference = if attachment.mime.starts_with("image/") { image_count += 1; format!("[image {image_count}]") } else { file_count += 1; format!("[file {file_count}]") }; ChatPromptAttachmentDto { reference, filename: attachment.filename.clone(), mime: attachment.mime.clone(), size_bytes: attachment.size_bytes, readable_path: attachment.readable_path.clone(), } }) .collect() } #[cfg(test)] mod chat_prompt_attachment_projection_tests { use super::*; fn attachment(filename: &str, mime: &str, size_bytes: u64) -> ChatAttachmentDto { ChatAttachmentDto { id: format!("id-{filename}"), filename: filename.to_owned(), mime: mime.to_owned(), size_bytes, source_kind: domain::ChatAttachmentSourceKind::Clipboard, storage_path: format!("agent-chat/session/id-{filename}-{filename}"), readable_path: format!("/project/.ideai/attachments/agent-chat/session/{filename}"), created_at: size_bytes, } } #[test] fn chat_prompt_attachment_refs_follow_import_order_and_kind_counters() { let attachments = vec![ attachment("first.png", "image/png", 10), attachment("notes.pdf", "application/pdf", 20), attachment("second.jpg", "image/jpeg", 30), ]; let refs = chat_prompt_attachment_refs(&attachments); assert_eq!(refs[0].reference, "[image 1]"); assert_eq!(refs[0].filename, "first.png"); assert_eq!(refs[1].reference, "[file 1]"); assert_eq!(refs[1].filename, "notes.pdf"); assert_eq!(refs[2].reference, "[image 2]"); assert_eq!(refs[2].filename, "second.jpg"); } #[test] fn prompt_with_imported_attachments_uses_canonical_refs() { let attachments = vec![ attachment("first.png", "image/png", 10), attachment("notes.pdf", "application/pdf", 20), ]; let refs = chat_prompt_attachment_refs(&attachments); let prompt = prompt_with_imported_attachments("describe", &attachments, &refs); assert!(prompt.contains("- [image 1] id: id-first.png; file: first.png")); assert!(prompt.contains("- [file 1] id: id-notes.pdf; file: notes.pdf")); assert!(prompt.contains("; path: /project/.ideai/attachments/agent-chat/session/notes.pdf")); } } /// `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)?; if preferred_view_for_agent(&state, project.id, agent_id).await == PreferredView::Chat { if let Some(session) = state .structured_sessions .session_for_agent_in_project(project.id, &agent_id) { return session .cancel_current_turn() .await .map(|_| ()) .map_err(|e| ErrorDto::from(AppError::from(e))); } } state .orchestrator_service .interrupt_agent(&project, agent_id) .await .map(|_| ()) .map_err(ErrorDto::from) } /// `cancel_agent_chat` — cancel only the current request of a live structured chat. /// /// The reusable session, conversation bridge and runtime state remain intact; /// agent-level teardown stays exclusively on [`stop_live_agent`]. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live /// structured session owns the id, `PROCESS` if a concrete stop fails). #[tauri::command] pub async fn cancel_agent_chat( session_id: String, state: State<'_, AppState>, ) -> Result { let sid = parse_session_id(&session_id)?; let (project_id, agent_id, _, _) = state .structured_sessions .meta_for_session(&sid) .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?; let output = state .cancel_agent_chat .execute(sid) .await .map_err(ErrorDto::from)?; if output.outcome == domain::ports::CancelCurrentTurnOutcome::Cancelled { let project = resolve_project(&project_id.to_string(), &state).await?; state .orchestrator_service .cancel_agent_turn_execution_children(&project, agent_id) .await .map_err(ErrorDto::from)?; } Ok(CancelAgentChatResponseDto::from(output)) } async fn preferred_view_for_agent( state: &AppState, project_id: domain::ProjectId, agent_id: domain::AgentId, ) -> PreferredView { let Ok(out) = state .load_layout .execute(LoadLayoutInput { project_id, layout_id: None, }) .await else { return PreferredView::Tui; }; preferred_view_in_node(&out.layout.root, agent_id).unwrap_or_default() } fn preferred_view_in_node(node: &LayoutNode, agent_id: domain::AgentId) -> Option { match node { LayoutNode::Leaf(leaf) if leaf.agent == Some(agent_id) => Some(leaf.preferred_view), LayoutNode::Leaf(_) | LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(split) => split .children .iter() .find_map(|child| preferred_view_in_node(&child.node, agent_id)), LayoutNode::Grid(grid) => grid .cells .iter() .find_map(|cell| preferred_view_in_node(&cell.node, agent_id)), } } /// `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 /// `project_id` + `agent_id` are an optional stable fallback for callers whose /// stored transport id predates a restart. The response always carries the /// effective live `sessionId`, its current scrollback and its provider /// `conversationId` when known. /// /// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if neither /// the requested/successor session nor the project-agent fallback is live). #[tauri::command] pub fn reattach_agent_chat( session_id: String, project_id: Option, agent_id: Option, on_reply: Channel, state: State<'_, AppState>, ) -> Result { let requested_sid = parse_session_id(&session_id)?; // A restart replaces the provider session id. Prefer the bridge successor // recorded by the restart-aware pump, then allow the caller to recover by // stable project/agent identity if it only retained a dead id. let mut sid = state.chat_bridge.successor(&requested_sid); if state.structured_sessions.session(&sid).is_none() { let explicit_owner = match (project_id.as_deref(), agent_id.as_deref()) { (Some(project_id), Some(agent_id)) => { Some((parse_project_id(project_id)?, parse_agent_id(agent_id)?)) } (None, None) => None, _ => { return Err(ErrorDto::from(AppError::Invalid( "projectId and agentId must be provided together".to_owned(), ))); } }; sid = match explicit_owner.or_else(|| state.chat_bridge.owner(&requested_sid)) { Some((project_id, agent_id)) => { let replacement = state .structured_sessions .session_id_for_agent_in_project(project_id, &agent_id) .ok_or_else(|| { ErrorDto::from(AppError::NotFound(format!( "live structured session for agent {agent_id}" ))) })?; state.chat_bridge.rebind_session(requested_sid, replacement); replacement } None => { return Err(ErrorDto::from(AppError::NotFound(format!( "structured session {requested_sid}" )))); } }; } let conversation_id = state .structured_sessions .meta_for_session(&sid) .and_then(|(_, _, _, conversation_id)| conversation_id); // (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::from_scrollback( sid.to_string(), conversation_id, scrollback, state.structured_sessions.transcript_restore_expected(&sid), )) } /// `detach_agent_chat` — detach only the custom chat view transport from a live /// structured session, preserving the agent process and retained scrollback. /// /// This is the backend distinction between a disappearing view /// (layout/tab/project switch, cell unmount) and [`close_agent_session`], which is /// an explicit destructive close. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live /// structured session owns the id). #[tauri::command] pub fn detach_agent_chat(session_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { let sid = parse_session_id(&session_id)?; if state.structured_sessions.session(&sid).is_none() { return Err(ErrorDto::from(AppError::NotFound(format!( "structured session {sid}" )))); } state.chat_bridge.detach(&sid); Ok(()) } /// `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::from_agent(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, } /// Request accepted by `open_plugin_layout_window`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenPluginLayoutWindowRequestDto { /// Provider plugin id. pub plugin_id: String, /// Layout type declared by the provider plugin. pub layout_type: String, /// Opaque plugin-owned initial/window state. #[serde(default)] pub state: serde_json::Value, } /// Response returned by `open_plugin_layout_window`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OpenPluginLayoutWindowResponseDto { /// Stable Tauri window label for this plugin layout. pub label: String, /// App URL loaded by the plugin-layout window. pub url: String, /// Whether the command reused and focused an existing window. pub already_open: bool, /// Runtime provider display name. pub provider_plugin_display_name: String, /// Layout display label. pub layout_label: String, /// Persistable plugin layout surface. pub surface: PersistedPluginLayoutWindow, } /// 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, } fn encode_label_part(raw: &str) -> String { URL_SAFE_NO_PAD.encode(raw.as_bytes()) } fn decode_label_part(raw: &str) -> Option { String::from_utf8(URL_SAFE_NO_PAD.decode(raw).ok()?).ok() } /// 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 { Context, Work, ScheduledTasks, Tickets, Mcp, Agents, Templates, Skills, Permissions, Memory, Git, } impl ViewPanel { pub(crate) fn parse(raw: &str) -> Result { match raw { "context" => Ok(Self::Context), "work" => Ok(Self::Work), "scheduledTasks" => Ok(Self::ScheduledTasks), "tickets" => Ok(Self::Tickets), "mcp" => Ok(Self::Mcp), "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::Context => "context", Self::Work => "work", Self::ScheduledTasks => "scheduledTasks", Self::Tickets => "tickets", Self::Mcp => "mcp", 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::Context => "Project context", Self::Work => "Work state", Self::ScheduledTasks => "Scheduled tasks", Self::Tickets => "Tickets", Self::Mcp => "MCP", 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()) } pub(crate) fn plugin_layout_window_label( plugin_id: &PluginId, layout_type: &PluginLayoutType, ) -> String { format!( "view-plugin-layout-{}.{}", encode_label_part(plugin_id.as_str()), encode_label_part(layout_type.as_str()) ) } pub(crate) fn plugin_layout_window_url(surface: &PersistedPluginLayoutWindow) -> String { let state = URL_SAFE_NO_PAD .encode(serde_json::to_vec(&surface.state).unwrap_or_else(|_| b"null".to_vec())); format!( "index.html?pluginLayout=1&pluginId={}&layoutType={}&state={}", encode_label_part(surface.plugin_id.as_str()), encode_label_part(surface.layout_type.as_str()), state ) } pub(crate) fn plugin_layout_window_from_label(label: &str) -> Option { let rest = label.strip_prefix("view-plugin-layout-")?; let (plugin_id, layout_type) = rest.split_once('.')?; Some(PersistedPluginLayoutWindow { plugin_id: PluginId::new(decode_label_part(plugin_id)?).ok()?, layout_type: PluginLayoutType::new(decode_label_part(layout_type)?).ok()?, state: serde_json::Value::Null, }) } 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, }) } /// `open_plugin_layout_window` — open or focus a detached OS window for a /// plugin-contributed layout. /// /// The contribution must exist in the plugin's existing `contributes.layouts` /// manifest surface and the plugin must be runtime-active. #[tauri::command] pub async fn open_plugin_layout_window( app: AppHandle, input: OpenPluginLayoutWindowRequestDto, state: State<'_, AppState>, ) -> Result { let plugin_id = PluginId::new(input.plugin_id).map_err(|e| ErrorDto { code: "INVALID".to_owned(), message: e.to_string(), })?; let layout_type = PluginLayoutType::new(input.layout_type).map_err(|e| ErrorDto { code: "INVALID".to_owned(), message: e.to_string(), })?; let out = state .open_plugin_layout_window .execute(OpenPluginLayoutWindowInput { plugin_id, layout_type, state: input.state, }) .await .map_err(ErrorDto::from)?; let label = plugin_layout_window_label(&out.surface.plugin_id, &out.surface.layout_type); let url = plugin_layout_window_url(&out.surface); state.set_plugin_window_surface(label.clone(), out.surface.clone()); if let Some(window) = app.get_webview_window(&label) { window.show().map_err(internal_window_error)?; window.set_focus().map_err(internal_window_error)?; return Ok(OpenPluginLayoutWindowResponseDto { label, url, already_open: true, provider_plugin_display_name: out.contribution.provider_plugin_display_name, layout_label: out.contribution.label, surface: out.surface, }); } let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into())) .title(format!("IdeA - {}", out.contribution.label)) .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 { if let Some(state) = event_app.try_state::() { state.clear_plugin_window_surface(&event_label); } } }); Ok(OpenPluginLayoutWindowResponseDto { label, url, already_open: false, provider_plugin_display_name: out.contribution.provider_plugin_display_name, layout_label: out.contribution.label, surface: out.surface, }) } /// `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 mcp_view_window_contract_is_stable() { let panel = ViewPanel::parse("mcp").unwrap(); assert_eq!(panel, ViewPanel::Mcp); assert_eq!(panel.as_str(), "mcp"); assert_eq!(panel.title(), "MCP"); assert_eq!(view_window_label(panel), "view-mcp"); assert_eq!(view_window_url(panel), "index.html?panel=mcp"); assert_eq!(view_panel_from_window_label("view-mcp"), Some(panel)); } #[test] fn scheduled_tasks_view_window_contract_is_stable() { let panel = ViewPanel::parse("scheduledTasks").unwrap(); assert_eq!(panel, ViewPanel::ScheduledTasks); assert_eq!(panel.as_str(), "scheduledTasks"); assert_eq!(panel.title(), "Scheduled tasks"); assert_eq!(view_window_label(panel), "view-scheduledTasks"); assert_eq!(view_window_url(panel), "index.html?panel=scheduledTasks"); assert_eq!( view_panel_from_window_label("view-scheduledTasks"), Some(panel) ); assert_eq!( view_panel_from_window_label("view-scheduledTasks-0000000000000000000000000000002a"), Some(panel) ); } #[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 projects_panel_is_not_detachable() { let err = ViewPanel::parse("projects").unwrap_err(); assert_eq!(err.code, "INVALID"); assert!(err.message.contains("invalid view panel: projects")); assert!(view_panel_from_window_label("view-projects").is_none()); } #[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}"); } #[test] fn plugin_layout_window_label_round_trips_surface_identity() { let plugin_id = PluginId::new("dev.idea.android-plugin").unwrap(); let layout_type = PluginLayoutType::new("idea-android.health").unwrap(); let label = plugin_layout_window_label(&plugin_id, &layout_type); let surface = plugin_layout_window_from_label(&label).unwrap(); assert!(label.starts_with("view-plugin-layout-")); assert_eq!(surface.plugin_id, plugin_id); assert_eq!(surface.layout_type, layout_type); assert_eq!(surface.state, serde_json::Value::Null); } #[test] fn plugin_layout_window_url_carries_surface_contract() { let surface = PersistedPluginLayoutWindow { plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), layout_type: PluginLayoutType::new("idea-android.health").unwrap(), state: serde_json::json!({ "deviceId": "pixel-8" }), }; let url = plugin_layout_window_url(&surface); assert!(url.starts_with("index.html?pluginLayout=1&")); assert!(url.contains("pluginId="), "url was {url}"); assert!(url.contains("layoutType="), "url was {url}"); assert!(url.contains("state="), "url was {url}"); } #[test] fn plugin_layout_window_response_payload_is_camel_case() { let payload = OpenPluginLayoutWindowResponseDto { label: "view-plugin-layout-x.y".to_owned(), url: "index.html?pluginLayout=1".to_owned(), already_open: false, provider_plugin_display_name: "Android".to_owned(), layout_label: "Android Health".to_owned(), surface: PersistedPluginLayoutWindow { plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(), layout_type: PluginLayoutType::new("idea-android.health").unwrap(), state: serde_json::json!({ "deviceId": "pixel-8" }), }, }; let json = serde_json::to_string(&payload).unwrap(); assert!(json.contains("\"alreadyOpen\":false"), "json was {json}"); assert!( json.contains("\"providerPluginDisplayName\":\"Android\""), "json was {json}" ); assert!( json.contains("\"layoutLabel\":\"Android Health\""), "json was {json}" ); assert!(!json.contains("provider_plugin_display_name")); } } /// `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: request.description, content: request.content, scope: request.scope, kind: request.kind, 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, rendezvous: None, parent_task_id: None, deadline_ms: request.deadline_ms, origin: domain::BackgroundTaskOrigin::Manual, }) .await .map(|out| BackgroundTaskDto::from(out.task)) .map_err(ErrorDto::from) } /// `cancel_background_task` — request cancellation of a running background task. /// /// Returns the task as currently persisted after the cancel request. /// /// # 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 { let id = parse_task_id(&task_id)?; state .cancel_background_task .execute(id) .await .map(|out| BackgroundTaskDto::from(out.task)) .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()) } // --------------------------------------------------------------------------- // Scheduled project tasks (#305) // --------------------------------------------------------------------------- /// Lists durable scheduled definitions for one project. #[tauri::command] pub async fn list_scheduled_project_tasks( project_id: String, state: State<'_, AppState>, ) -> Result, ErrorDto> { state .scheduled_project_tasks .list(parse_project_id(&project_id)?) .await .map_err(ErrorDto::from) } /// Creates a human-approved scheduled project task definition. #[tauri::command] pub async fn create_scheduled_project_task( request: crate::dto::CreateScheduledProjectTaskRequestDto, state: State<'_, AppState>, ) -> Result { state .scheduled_project_tasks .create(application::CreateScheduledProjectTaskInput { project_id: parse_project_id(&request.project_id)?, name: request.name, description: request.description, command: request.command, schedule: request.schedule, timezone: request.timezone, enabled: request.enabled, timeout_ms: request.timeout_ms, actor: domain::ScheduledTaskActor::Human, }) .await .map_err(ErrorDto::from) } /// Replaces a scheduled project task definition under revision control. #[tauri::command] pub async fn update_scheduled_project_task( request: crate::dto::UpdateScheduledProjectTaskRequestDto, state: State<'_, AppState>, ) -> Result { state .scheduled_project_tasks .update(application::UpdateScheduledProjectTaskInput { project_id: parse_project_id(&request.project_id)?, scheduled_task_id: crate::dto::parse_scheduled_task_id(&request.scheduled_task_id)?, name: request.name, description: request.description, command: request.command, schedule: request.schedule, timezone: request.timezone, enabled: request.enabled, timeout_ms: request.timeout_ms, audit_revision: request.audit_revision, actor: domain::ScheduledTaskActor::Human, }) .await .map_err(ErrorDto::from) } /// Deletes a definition without cancelling a run already in progress. #[tauri::command] pub async fn delete_scheduled_project_task( project_id: String, scheduled_task_id: String, state: State<'_, AppState>, ) -> Result<(), ErrorDto> { state .scheduled_project_tasks .delete( parse_project_id(&project_id)?, crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, domain::ScheduledTaskActor::Human, ) .await .map_err(ErrorDto::from) } /// Starts an immediate background run for an approved definition. #[tauri::command] pub async fn run_scheduled_project_task_now( project_id: String, scheduled_task_id: String, state: State<'_, AppState>, ) -> Result { state .scheduled_project_tasks .run_now( parse_project_id(&project_id)?, crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, domain::ScheduledTaskActor::Human, ) .await .map_err(ErrorDto::from) } /// Enables/approves or disables a scheduled definition. #[tauri::command] pub async fn set_scheduled_project_task_enabled( project_id: String, scheduled_task_id: String, enabled: bool, state: State<'_, AppState>, ) -> Result { state .scheduled_project_tasks .set_enabled( parse_project_id(&project_id)?, crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, enabled, domain::ScheduledTaskActor::Human, ) .await .map_err(ErrorDto::from) }