diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 99ab9d1..148ea1a 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -34,31 +34,66 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput, - CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput, - LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput, + AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, + AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput, + CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, + DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, + DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, + GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, + GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, + LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, + ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, - ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput, - ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput, - TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, + ReadAgentContextInput, ReadConversationPageInput, ReadIssueCarnetInput, ReadIssueInput, + ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, + UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, + UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateIssueCarnetInput, UpdateMemoryInput, UpdateProjectContextInput, + UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateSkillInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; use domain::IssueActor; -use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash}; +use domain::{ + AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash, SkillRef, + SkillScope, +}; use backend::dto::{ create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id, - parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto, - parse_sprint_status_dto, parse_task_id, sort_ticket_rows, sprint_version_dto, update_input, - version_dto, BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, - LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, - SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, - SprintRenameRequestDto, SprintReorderRequestDto, TerminalSessionDto, TicketAssignRequestDto, - TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, - TicketLinkCommandRequestDto, TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, - TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, - TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, + parse_profile_id, parse_project_id, parse_ref_dto, parse_session_id, parse_skill_id, + parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id, + sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, + AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, + BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, + CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto, + CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto, + DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, + EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, + MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, + SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, + SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, + TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, + TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, + TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, + UpdateSkillRequestDto, UpdateTemplateRequestDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -2305,6 +2340,93 @@ async fn invoke( "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, + "read_project_context" => invoke_read_project_context(&request.args, &state.app).await, + "update_project_context" => invoke_update_project_context(&request.args, &state.app).await, + "first_run_state" => invoke_first_run_state(&state.app).await, + "reference_profiles" => invoke_reference_profiles(&state.app).await, + "detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await, + "list_profiles" => invoke_list_profiles(&state.app).await, + "save_profile" => invoke_save_profile(&request.args, &state.app).await, + "delete_profile" => invoke_delete_profile(&request.args, &state.app).await, + "configure_profiles" => invoke_configure_profiles(&request.args, &state.app).await, + "clone_opencode_profile_from_seed" => { + invoke_clone_opencode_profile_from_seed(&request.args, &state.app).await + } + "list_agents" => invoke_list_agents(&request.args, &state.app).await, + "list_live_agents" => invoke_list_live_agents(&request.args, &state.app), + "list_resumable_agents" => invoke_list_resumable_agents(&request.args, &state.app).await, + "attach_live_agent" => invoke_attach_live_agent(&request.args, &state.app).await, + "stop_live_agent" => invoke_stop_live_agent(&request.args, &state.app).await, + "create_agent" => invoke_create_agent(&request.args, &state.app).await, + "change_agent_profile" => invoke_change_agent_profile(&request.args, &state.app).await, + "read_agent_context" => invoke_read_agent_context(&request.args, &state.app).await, + "update_agent_context" => invoke_update_agent_context(&request.args, &state.app).await, + "delete_agent" => invoke_delete_agent(&request.args, &state.app).await, + "inspect_conversation" => invoke_inspect_conversation(&request.args, &state.app).await, + "list_templates" => invoke_list_templates(&state.app).await, + "create_template" => invoke_create_template(&request.args, &state.app).await, + "update_template" => invoke_update_template(&request.args, &state.app).await, + "delete_template" => invoke_delete_template(&request.args, &state.app).await, + "create_agent_from_template" => { + invoke_create_agent_from_template(&request.args, &state.app).await + } + "detect_agent_drift" => invoke_detect_agent_drift(&request.args, &state.app).await, + "sync_agent_with_template" => { + invoke_sync_agent_with_template(&request.args, &state.app).await + } + "list_skills" => invoke_list_skills(&request.args, &state.app).await, + "create_skill" => invoke_create_skill(&request.args, &state.app).await, + "update_skill" => invoke_update_skill(&request.args, &state.app).await, + "delete_skill" => invoke_delete_skill(&request.args, &state.app).await, + "assign_skill_to_agent" => invoke_assign_skill_to_agent(&request.args, &state.app).await, + "unassign_skill_from_agent" => { + invoke_unassign_skill_from_agent(&request.args, &state.app).await + } + "get_project_permissions" => { + invoke_get_project_permissions(&request.args, &state.app).await + } + "update_project_permissions" => { + invoke_update_project_permissions(&request.args, &state.app).await + } + "update_agent_permissions" => { + invoke_update_agent_permissions(&request.args, &state.app).await + } + "resolve_agent_permissions" => { + invoke_resolve_agent_permissions(&request.args, &state.app).await + } + "get_mcp_tool_permissions" => { + invoke_get_mcp_tool_permissions(&request.args, &state.app).await + } + "update_project_mcp_tool_permissions" => { + invoke_update_project_mcp_tool_permissions(&request.args, &state.app).await + } + "update_agent_mcp_tool_permissions" => { + invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await + } + "list_memories" => invoke_list_memories(&request.args, &state.app).await, + "get_memory" => invoke_get_memory(&request.args, &state.app).await, + "create_memory" => invoke_create_memory(&request.args, &state.app).await, + "update_memory" => invoke_update_memory(&request.args, &state.app).await, + "delete_memory" => invoke_delete_memory(&request.args, &state.app).await, + "read_memory_index" => invoke_read_memory_index(&request.args, &state.app).await, + "resolve_memory_links" => invoke_resolve_memory_links(&request.args, &state.app).await, + "recall_memory" => invoke_recall_memory(&request.args, &state.app).await, + "list_embedder_profiles" => invoke_list_embedder_profiles(&state.app).await, + "save_embedder_profile" => invoke_save_embedder_profile(&request.args, &state.app).await, + "delete_embedder_profile" => { + invoke_delete_embedder_profile(&request.args, &state.app).await + } + "describe_embedder_engines" => invoke_describe_embedder_engines(&state.app).await, + "git_status" => invoke_git_status(&request.args, &state.app).await, + "git_stage" => invoke_git_stage(&request.args, &state.app).await, + "git_unstage" => invoke_git_unstage(&request.args, &state.app).await, + "git_commit" => invoke_git_commit(&request.args, &state.app).await, + "git_branches" => invoke_git_branches(&request.args, &state.app).await, + "git_checkout" => invoke_git_checkout(&request.args, &state.app).await, + "git_log" => invoke_git_log(&request.args, &state.app).await, + "git_init" => invoke_git_init(&request.args, &state.app).await, + "git_graph" => invoke_git_graph(&request.args, &state.app).await, + "read_conversation_page" => invoke_read_conversation_page(&request.args, &state.app).await, "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, @@ -2373,6 +2495,294 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "read_project_context")?, + state, + ) + .await?; + let output = state + .read_project_context + .execute(ReadProjectContextInput { project }) + .await + .map_err(ErrorDto::from)? + .content; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_context( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_project_context", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .update_project_context + .execute(UpdateProjectContextInput { + project, + content: request.content, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_first_run_state(state: &BackendCore) -> Result { + let output = state + .first_run_state + .execute() + .await + .map(FirstRunStateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_reference_profiles(state: &BackendCore) -> Result { + let output = state + .reference_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_detect_profiles(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("detect_profiles", args)?; + let output = state + .detect_profiles + .execute(request.into()) + .await + .map(DetectProfilesResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_profiles(state: &BackendCore) -> Result { + let output = state + .list_profiles + .execute() + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_save_profile(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("save_profile", args)?; + let output = state + .save_profile + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_profile(args: &Value, state: &BackendCore) -> Result { + let profile_id = string_arg(args, "profileId", "delete_profile")?; + let input = backend::dto::parse_delete_profile(profile_id)?; + state + .delete_profile + .execute(input) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_configure_profiles(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("configure_profiles", args)?; + let output = state + .configure_profiles + .execute(request.into()) + .await + .map(ProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_clone_opencode_profile_from_seed( + args: &Value, + state: &BackendCore, +) -> Result { + let request = optional_request::(args)?.unwrap_or( + CloneOpenCodeProfileFromSeedRequestDto { + name: None, + opencode: None, + }, + ); + let output = state + .clone_opencode_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_agents(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_agents")?, state).await?; + let output = state + .list_agents + .execute(ListAgentsInput { project }) + .await + .map(AgentListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +fn invoke_list_live_agents(args: &Value, state: &BackendCore) -> Result { + let _ = parse_project_id(string_arg(args, "projectId", "list_live_agents")?)?; + let live = LiveSessions::new( + Arc::clone(&state.terminal_sessions), + Arc::clone(&state.structured_sessions), + ); + let output = LiveAgentListDto::from_snapshots(live.live_agent_snapshots()); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_resumable_agents( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "list_resumable_agents")?, + state, + ) + .await?; + let output = state + .list_resumable_agents + .execute(ListResumableAgentsInput { project }) + .await + .map(ResumableAgentListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_attach_live_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("attach_live_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .attach_live_agent + .execute(AttachLiveAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + node_id: parse_node_id(&request.node_id)?, + }) + .map(AttachLiveAgentResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("stop_live_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .stop_live_agent + .execute(StopLiveAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(StopLiveAgentResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_agent(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_agent + .execute(CreateAgentInput { + project, + name: request.name, + profile_id: parse_profile_id(&request.profile_id)?, + initial_content: request.initial_content, + }) + .await + .map(AgentDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_change_agent_profile(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("change_agent_profile", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .change_agent_profile + .execute(ChangeAgentProfileInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + profile_id: parse_profile_id(&request.profile_id)?, + rows: request.rows, + cols: request.cols, + }) + .await + .map(ChangeAgentProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_read_agent_context(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "read_agent_context")?, state) + .await?; + let agent_id = parse_agent_id(string_arg(args, "agentId", "read_agent_context")?)?; + let output = state + .read_agent_context + .execute(ReadAgentContextInput { project, agent_id }) + .await + .map(ReadAgentContextResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_context(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_agent_context", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .update_agent_context + .execute(UpdateAgentContextInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + content: request.content, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_delete_agent(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_agent")?, state).await?; + let agent_id = parse_agent_id(string_arg(args, "agentId", "delete_agent")?)?; + state + .delete_agent + .execute(DeleteAgentInput { project, agent_id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_inspect_conversation(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("inspect_conversation", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .inspect_conversation + .execute(InspectConversationInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + conversation_id: request.conversation_id, + }) + .await + .map(ConversationDetailsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_get_project_work_state( args: &Value, state: &BackendCore, @@ -2790,6 +3200,673 @@ async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result Result { + let output = state + .list_templates + .execute() + .await + .map(TemplateListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_template(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_template", args)?; + let output = state + .create_template + .execute(request.into_input()?) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_template(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_template", args)?; + let output = state + .update_template + .execute(request.into_input()?) + .await + .map(TemplateDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_template(args: &Value, state: &BackendCore) -> Result { + let template_id = parse_template_id(string_arg(args, "templateId", "delete_template")?)?; + state + .delete_template + .execute(DeleteTemplateInput { template_id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_create_agent_from_template( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("create_agent_from_template", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_agent_from_template + .execute(request.into_input(project)?) + .await + .map(|out| AgentDto(out.agent)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_detect_agent_drift(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "detect_agent_drift")?, state) + .await?; + let output = state + .detect_agent_drift + .execute(DetectAgentDriftInput { project }) + .await + .map(AgentDriftListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_sync_agent_with_template( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("sync_agent_with_template", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .sync_agent_with_template + .execute(SyncAgentWithTemplateInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(SyncResultDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_skills(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_skills")?, state).await?; + let scope = skill_scope_arg(args, "list_skills")?; + let output = state + .list_skills + .execute(ListSkillsInput { + scope, + project_root: project.root, + }) + .await + .map(SkillListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_skill", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .create_skill + .execute(CreateSkillInput { + name: request.name, + description: None, + content: request.content, + scope: request.scope, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_skill(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_skill", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_skill + .execute(UpdateSkillInput { + scope: request.scope, + skill_id: parse_skill_id(&request.skill_id)?, + content: request.content, + project_root: project.root, + }) + .await + .map(SkillDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_skill(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_skill")?, state).await?; + let scope = skill_scope_arg(args, "delete_skill")?; + let skill_id = parse_skill_id(string_arg(args, "skillId", "delete_skill")?)?; + state + .delete_skill + .execute(DeleteSkillInput { + scope, + skill_id, + project_root: project.root, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_assign_skill_to_agent( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("assign_skill_to_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .assign_skill + .execute(AssignSkillToAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + skill: SkillRef::new(parse_skill_id(&request.skill_id)?, request.scope), + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_unassign_skill_from_agent( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("unassign_skill_from_agent", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .unassign_skill + .execute(UnassignSkillFromAgentInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + skill_id: parse_skill_id(&request.skill_id)?, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_get_project_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "get_project_permissions")?, + state, + ) + .await?; + let output = state + .get_project_permissions + .execute(application::GetProjectPermissionsInput { project }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_project_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_project_permissions + .execute(UpdateProjectPermissionsInput { + project, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("update_agent_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_agent_permissions + .execute(UpdateAgentPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_resolve_agent_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("resolve_agent_permissions", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .resolve_agent_permissions + .execute(ResolveAgentPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + }) + .await + .map(|out| out.effective.map(EffectivePermissionsDto)) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_get_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "get_mcp_tool_permissions")?, + state, + ) + .await?; + let output = state + .read_mcp_tool_permissions + .execute(ReadMcpToolPermissionsInput { project }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_project_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "update_project_mcp_tool_permissions", + args, + )?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_project_mcp_tool_permissions + .execute(UpdateProjectMcpToolPermissionsInput { + project, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_agent_mcp_tool_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "update_agent_mcp_tool_permissions", + args, + )?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_agent_mcp_tool_permissions + .execute(UpdateAgentMcpToolPermissionsInput { + project, + agent_id: parse_agent_id(&request.agent_id)?, + policy: request.policy, + }) + .await + .map(ProjectMcpToolPermissionsDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_memories(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?; + let output = state + .list_memories + .execute(ListMemoriesInput { + project_root: project.root, + }) + .await + .map(MemoryListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_get_memory(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "get_memory")?, state).await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "get_memory")?)?; + let output = state + .get_memory + .execute(GetMemoryInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_create_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("create_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = 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)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_update_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("update_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .update_memory + .execute(UpdateMemoryInput { + project_root: project.root, + slug: backend::dto::parse_memory_slug(&request.slug)?, + description: request.description, + r#type: request.r#type, + content: request.content, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_memory(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "delete_memory")?, state).await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "delete_memory")?)?; + state + .delete_memory + .execute(DeleteMemoryInput { + project_root: project.root, + slug, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_read_memory_index(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "read_memory_index")?, state) + .await?; + let output = state + .read_memory_index + .execute(ReadMemoryIndexInput { + project_root: project.root, + }) + .await + .map(MemoryIndexDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_resolve_memory_links(args: &Value, state: &BackendCore) -> Result { + let project = resolve_project_readonly( + string_arg(args, "projectId", "resolve_memory_links")?, + state, + ) + .await?; + let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "resolve_memory_links")?)?; + let output = state + .resolve_memory_links + .execute(ResolveMemoryLinksInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryLinksDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_recall_memory(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("recall_memory", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = 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)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_embedder_profiles(state: &BackendCore) -> Result { + let output = state + .list_embedder_profiles + .execute() + .await + .map(EmbedderProfileListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_save_embedder_profile( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::("save_embedder_profile", args)?; + let output = state + .save_embedder_profile + .execute(request.into()) + .await + .map(EmbedderProfileDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_delete_embedder_profile( + args: &Value, + state: &BackendCore, +) -> Result { + let id = string_arg(args, "embedderId", "delete_embedder_profile")?.to_owned(); + state + .delete_embedder_profile + .execute(DeleteEmbedderProfileInput { id }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_describe_embedder_engines(state: &BackendCore) -> Result { + let output = state + .describe_embedder_engines + .execute() + .await + .map(EmbedderEnginesDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_status(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_status")?, state).await?; + let output = state + .git_status + .execute(GitStatusInput { + root: project.root.as_str().to_owned(), + }) + .await + .map(GitStatusListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_stage(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_stage", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_stage + .execute(GitStagePathInput { + root: project.root.as_str().to_owned(), + path: request.path, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_unstage(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_unstage", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_unstage + .execute(GitStagePathInput { + root: project.root.as_str().to_owned(), + path: request.path, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_commit(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_commit", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .git_commit + .execute(GitCommitInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + message: request.message, + }) + .await + .map(GitCommitDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_branches(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_branches")?, state).await?; + let output = state + .git_branches + .execute(GitBranchesInput { + root: project.root.as_str().to_owned(), + }) + .await + .map(GitBranchesDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_checkout(args: &Value, state: &BackendCore) -> Result { + let request = required_request::("git_checkout", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + state + .git_checkout + .execute(GitCheckoutInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + branch: request.branch, + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_log(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_log")?, state).await?; + let output = state + .git_log + .execute(GitLogInput { + root: project.root.as_str().to_owned(), + limit: usize_arg(args, "limit").unwrap_or(50), + }) + .await + .map(GitCommitListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_git_init(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_init")?, state).await?; + state + .git_init + .execute(GitInitInput { + project_id: project.id, + root: project.root.as_str().to_owned(), + }) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_git_graph(args: &Value, state: &BackendCore) -> Result { + let project = + resolve_project_readonly(string_arg(args, "projectId", "git_graph")?, state).await?; + let output = state + .git_graph + .execute(GitGraphInput { + root: project.root.as_str().to_owned(), + limit: usize_arg(args, "limit").unwrap_or(200), + }) + .await + .map(GraphCommitListDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_read_conversation_page( + args: &Value, + state: &BackendCore, +) -> Result { + let request = + required_request::("read_conversation_page", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let conversation = 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 output = state + .read_conversation_page + .execute(ReadConversationPageInput { + project_root: project.root, + conversation, + cursor: request.cursor(), + limit: request.limit.unwrap_or(0), + }) + .await + .map(TurnPageDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + fn task_id_arg(args: &Value, command: &str) -> Result { args.get("taskId") .and_then(Value::as_str) @@ -2800,6 +3877,45 @@ fn task_id_arg(args: &Value, command: &str) -> Result .and_then(parse_task_id) } +fn arg_container(args: &Value) -> Result<&Value, ErrorDto> { + match args { + Value::Object(map) => Ok(map.get("request").unwrap_or(args)), + _ => Err(ErrorDto { + code: "INVALID".to_owned(), + message: "args must be an object".to_owned(), + }), + } +} + +fn string_arg<'a>(args: &'a Value, key: &str, command: &str) -> Result<&'a str, ErrorDto> { + arg_container(args)? + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.{key}"), + }) +} + +fn usize_arg(args: &Value, key: &str) -> Option { + arg_container(args) + .ok() + .and_then(|container| container.get(key)) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn skill_scope_arg(args: &Value, command: &str) -> Result { + let raw = arg_container(args)? + .get("scope") + .cloned() + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.scope"), + })?; + serde_json::from_value(raw).map_err(invalid_args_error) +} + async fn verify_background_task_project( args: &Value, state: &BackendCore, @@ -4226,6 +5342,29 @@ mod tests { (status, body) } + async fn assert_invoke_command_is_routed(state: Arc, cookie: &str, command: &str) { + let (status, body) = invoke_request_for_test(state, cookie, command, json!({})).await; + + assert_ne!( + body["code"], "UNKNOWN_COMMAND", + "{command} must be allowlisted via /api/invoke" + ); + assert!( + status == StatusCode::OK || status == StatusCode::BAD_REQUEST, + "{command} returned unexpected status {status}: {body}" + ); + } + + async fn assert_invoke_commands_are_routed( + state: &Arc, + cookie: &str, + commands: &[&str], + ) { + for command in commands { + assert_invoke_command_is_routed(Arc::clone(state), cookie, command).await; + } + } + async fn create_raw_cli_agent_for_test( state: &Arc, name: &str, @@ -6346,6 +7485,178 @@ mod tests { } } + #[tokio::test] + async fn web_invoke_routes_context_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &["read_project_context", "update_project_context"], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_profile_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "first_run_state", + "reference_profiles", + "detect_profiles", + "list_profiles", + "save_profile", + "delete_profile", + "configure_profiles", + "clone_opencode_profile_from_seed", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_agent_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_agents", + "list_live_agents", + "list_resumable_agents", + "attach_live_agent", + "stop_live_agent", + "create_agent", + "change_agent_profile", + "read_agent_context", + "update_agent_context", + "delete_agent", + "inspect_conversation", + "read_conversation_page", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_template_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_templates", + "create_template", + "update_template", + "delete_template", + "create_agent_from_template", + "detect_agent_drift", + "sync_agent_with_template", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_skill_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_skills", + "create_skill", + "update_skill", + "delete_skill", + "assign_skill_to_agent", + "unassign_skill_from_agent", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_permission_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "get_project_permissions", + "update_project_permissions", + "update_agent_permissions", + "resolve_agent_permissions", + "get_mcp_tool_permissions", + "update_project_mcp_tool_permissions", + "update_agent_mcp_tool_permissions", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_memory_and_embedder_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "list_memories", + "get_memory", + "create_memory", + "update_memory", + "delete_memory", + "read_memory_index", + "resolve_memory_links", + "recall_memory", + "list_embedder_profiles", + "save_embedder_profile", + "delete_embedder_profile", + "describe_embedder_engines", + ], + ) + .await; + } + + #[tokio::test] + async fn web_invoke_routes_git_commands() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert_invoke_commands_are_routed( + &state, + &cookie, + &[ + "git_status", + "git_stage", + "git_unstage", + "git_commit", + "git_branches", + "git_checkout", + "git_log", + "git_init", + "git_graph", + ], + ) + .await; + } + #[tokio::test] async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() { let state = state();