//! [`WorkspaceOps`] — workspace operations handle. //! //! Extensions dispatch through [`WorkspaceHandle`]; tool calls dispatch //! through the workspace session's [`FinalizedToolset`]. The toolset is //! installed via [`WorkspaceOps::bind_local_session`] after the agent is //! built. //! //! ## Type safety //! //! Each operation has a corresponding request struct that implements //! [`WorkspaceRpc`]. The struct carries a `METHOD` constant and derives //! `Serialize + Deserialize`, so op identity and payload shape live in //! one place. use crate::error::{WorkspaceError, WorkspaceResult}; use crate::file_system::ContentSearchRequest; use crate::handle::WorkspaceHandle; use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest}; use async_trait::async_trait; use kigi_tools::types::output::ToolRunResult; pub use kigi_workspace_types::rpc::WorkspaceRpc; pub use kigi_workspace_types::rpc::agents_md::DiscoverAgentsMdReq; pub use kigi_workspace_types::rpc::code_nav::{ CodeFindDefinitionsReq, CodeFindReferencesReq, CodeGotoDefinitionReq, CodeGotoReferencesReq, CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse, }; pub use kigi_workspace_types::rpc::fs::{ GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes, PutFileEntry, PutFileResult, PutFilesReq, PutFilesRes, }; pub use kigi_workspace_types::rpc::git::{ BinaryFileInfoData, CheckoutCommitResponse, CommitWithPatchData, DetectVcsKindReq, DiffStatsSummary, GitBranchesReq, GitCheckoutCommitReq, GitCheckoutReq, GitCollectChangesReq, GitCollectChangesResponse, GitCommitReq, GitCurrentCommitReq, GitDiffReq, GitDiscardReq, GitFilesReq, GitInfoReq, GitResolveRootReq, GitStageContentReq, GitStageReq, GitStashReq, GitStatusExtReq, GitStatusExtResponse, GitStatusFormat, GitStatusReq, GitUnstageReq, IdentityData, PublicBaseData, RepoInfo, UNTRACKED_CONTENT_THRESHOLD, UncommittedChangesData, UntrackedFileData, }; pub use kigi_workspace_types::rpc::hooks::{ HookEventNameWire, HookRegistryReq, HookRegistryWire, HookSpecWire, }; pub use kigi_workspace_types::rpc::hunks::{ BulkHunkActionResponse, FileContentEntryWire, FileContentStatusWire, FileContentViewWire, FileSummary, FilteredHunksResponse, HunkActionKind, HunkActionReq, HunkActionResponse, HunkAllActionReq, HunkFileActionReq, HunkGetAllFileContentsReq, HunkGetAllHunksReq, HunkGetFileSummariesReq, HunkGetFilteredHunksReq, HunkGetSessionSummaryReq, HunkGetStagedFilesReq, HunkLineInfoWire, HunkSingleActionReq, HunkSourceWire, HunkTurnActionReq, HunkWire, SessionStatsWire, SessionSummaryWire, TurnSummaryWire, }; pub use kigi_workspace_types::rpc::search::{FuzzyChangeReq, FuzzyCloseReq, FuzzyOpenReq}; pub use kigi_workspace_types::rpc::session::{BeginPromptReq, EndPromptReq, RewindToReq}; pub use kigi_workspace_types::rpc::skills::DiscoverSkillsReq; pub use kigi_workspace_types::rpc::workspace::WorkspaceInfoReq; pub use kigi_workspace_types::rpc::worktree::{ CreateWorktreeFromWorktreeRequestWire, CreateWorktreeFromWorktreeSyncReq, PrepareWorktreeFromWorktreeResponse, WorktreeDbPathReq, WorktreeDbPathResponse, WorktreeDbRebuildReq, WorktreeDbStatsReq, WorktreeGcReq, WorktreeListReq, WorktreeShowReq, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; /// Implements [`WorkspaceRpc`] for request types whose responses /// reference crate-internal types and so cannot live in the types crate. macro_rules! workspace_rpc { ($ty:ty, $method:literal, $resp:ty) => { impl crate::workspace_ops::WorkspaceRpc for $ty { const METHOD: &'static str = $method; type Response = $resp; } }; } /// Typed workspace operation: the wire contract (`METHOD`, `Response`) /// comes from the [`WorkspaceRpc`] supertrait; this adds local-mode /// `execute()`. In proxy mode the op is serialized through the server RPC. #[async_trait] pub trait WorkspaceOp: WorkspaceRpc + DeserializeOwned + Send + Sync { /// Execute the operation locally against the workspace handle. async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult; } /// Prepare a worktree fork from an existing worktree (validation + path resolution). /// Returns a serialized result with `spawn_task` flag and the response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrepareWorktreeFromWorktreeReq { pub inner: crate::worktree::CreateWorktreeFromWorktreeRequest, } /// Get all rewind points for the session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetRewindPointsReq { pub session_id: String, } impl WorkspaceRpc for GetRewindPointsReq { const METHOD: &'static str = "workspace.get_rewind_points"; type Response = Vec; } fn hunk_line_info_to_wire(info: &kigi_hunk_tracker::types::HunkLineInfo) -> HunkLineInfoWire { HunkLineInfoWire { old_start: info.old_start, old_count: info.old_count, new_start: info.new_start, new_count: info.new_count, } } fn hunk_source_to_wire(source: kigi_hunk_tracker::types::HunkSource) -> HunkSourceWire { use kigi_hunk_tracker::types::HunkSource as S; match source { S::AgentEdit { prompt_index } => HunkSourceWire::AgentEdit { prompt_index }, S::ExternalEditOnAgentFile => HunkSourceWire::ExternalEditOnAgentFile, S::External => HunkSourceWire::External, } } fn hunk_to_wire(hunk: &kigi_hunk_tracker::types::Hunk) -> HunkWire { HunkWire { id: hunk.id.as_str().to_owned(), path: hunk.path.clone(), line_info: hunk_line_info_to_wire(&hunk.line_info), source: hunk_source_to_wire(hunk.source), old_text: hunk.old_text.clone(), new_text: hunk.new_text.clone(), patch: hunk.patch.clone(), created_at: hunk.created_at, } } fn file_content_status_to_wire( status: kigi_hunk_tracker::types::FileContentStatus, ) -> FileContentStatusWire { use kigi_hunk_tracker::types::FileContentStatus as S; match status { S::Missing => FileContentStatusWire::Missing, S::Binary => FileContentStatusWire::Binary, S::TooLarge => FileContentStatusWire::TooLarge, S::LfsPointer => FileContentStatusWire::LfsPointer, S::Symlink => FileContentStatusWire::Symlink, S::Full => FileContentStatusWire::Full, } } fn file_content_view_to_wire( view: kigi_hunk_tracker::types::FileContentView, ) -> FileContentViewWire { FileContentViewWire { status: file_content_status_to_wire(view.status), byte_len: view.byte_len, content: view.content, } } fn file_content_entry_to_wire(entry: kigi_hunk_tracker::FileContentEntry) -> FileContentEntryWire { FileContentEntryWire { path: entry.path, baseline: file_content_view_to_wire(entry.baseline), current: file_content_view_to_wire(entry.current), is_agent_file: entry.is_agent_file, staged: entry.staged, } } fn session_stats_to_wire(stats: &kigi_hunk_tracker::types::SessionStats) -> SessionStatsWire { SessionStatsWire { accepted_hunks: stats.accepted_hunks, rejected_hunks: stats.rejected_hunks, accepted_lines_added: stats.accepted_lines_added, accepted_lines_removed: stats.accepted_lines_removed, rejected_lines_added: stats.rejected_lines_added, rejected_lines_removed: stats.rejected_lines_removed, } } fn turn_summary_to_wire(turn: kigi_hunk_tracker::types::TurnSummary) -> TurnSummaryWire { TurnSummaryWire { prompt_index: turn.prompt_index, files: turn.files, pending_hunks: turn.pending_hunks.iter().map(|h| hunk_to_wire(h)).collect(), lines_added: turn.lines_added, lines_removed: turn.lines_removed, } } fn session_summary_to_wire(summary: kigi_hunk_tracker::SessionSummary) -> SessionSummaryWire { SessionSummaryWire { stats: session_stats_to_wire(&summary.stats), turns: summary .turns .into_iter() .map(turn_summary_to_wire) .collect(), files_modified: summary.files_modified, files_with_pending: summary.files_with_pending, pending_hunks: summary.pending_hunks, pending_lines_added: summary.pending_lines_added, pending_lines_removed: summary.pending_lines_removed, unattributed_pending: summary.unattributed_pending, } } /// Convert a wire [`HunkActionKind`] to the hunk-tracker crate's `HunkAction`. fn tracker_action(kind: HunkActionKind) -> kigi_hunk_tracker::types::HunkAction { match kind { HunkActionKind::Accept => kigi_hunk_tracker::types::HunkAction::Accept, HunkActionKind::Reject => kigi_hunk_tracker::types::HunkAction::Reject, } } /// Access the per-session hunk tracker; the op must carry a session. fn session_tracker( ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let sid = session_id .ok_or_else(|| WorkspaceError::Internal("per-session hunk op requires a session".into()))?; let session = ws .session(sid) .ok_or_else(|| WorkspaceError::SessionNotFound(sid.to_owned()))?; Ok(session.hunk_tracker().clone()) } /// Resolve the directory a git op runs in: the explicit `git_root` when the /// caller provides one (the per-session repo, which the desktop sends per /// window), else the workspace root. Without this, every session's git /// queries/mutations would target the workspace launch directory's repo. fn git_op_cwd( ws: &WorkspaceHandle, git_root: &Option, ) -> WorkspaceResult { match git_root { Some(root) => Ok(root.clone()), None => ws.root_cwd(), } } #[async_trait] impl WorkspaceOp for GitStatusExtReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; match self.format { GitStatusFormat::Structured => { let data = crate::session::git::status( &cwd, self.include_untracked, self.include_stats, self.ignore_submodules, self.include_patches, ) .await .map_err(|e| WorkspaceError::Internal(e.to_string()))?; Ok(GitStatusExtResponse::structured(data)) } GitStatusFormat::Prompt => { let result = crate::file_system::git_status(cwd) .await .map_err(|e| WorkspaceError::Internal(e.to_string()))?; Ok(GitStatusExtResponse::prompt(result)) } } } } #[async_trait] impl WorkspaceOp for GitFilesReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::read_files(&cwd, &self.paths, &self.version) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitDiffReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::diffs( &cwd, self.paths.as_deref(), &self.from, &self.to, self.include_patch, self.include_content, self.merge_base, ) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitStageReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::stage(&cwd, self.paths.clone()) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitStageContentReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::stage_content(&cwd, &self.path, &self.content) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitUnstageReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::unstage(&cwd, self.paths.clone()) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitDiscardReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::discard(&cwd, self.paths.clone(), self.scope, self.include_untracked) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitCommitReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::commit( &cwd, &self.message, self.amend, self.signoff, self.push, self.sync, ) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitCheckoutReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::checkout_branch(&cwd, &self.branch, self.create) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitStashReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::stash(&cwd, self.include_untracked) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitInfoReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::git_info(&cwd) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitBranchesReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = git_op_cwd(ws, &self.git_root)?; crate::session::git::list_branches(&cwd) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for GitCollectChangesReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { { return Err(WorkspaceError::Internal( "git collect changes is unavailable in this build".to_string(), )); } } } #[async_trait] impl WorkspaceOp for GitResolveRootReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { Ok(crate::session::git::find_git_root_from_path(&self.cwd).ok()) } } #[async_trait] impl WorkspaceOp for GitCurrentCommitReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { Ok(crate::session::git::get_current_commit(&self.git_root).await) } } #[async_trait] impl WorkspaceOp for DetectVcsKindReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { Ok(crate::session::git::detect_vcs_kind(&self.path)) } } #[async_trait] impl WorkspaceOp for GitCheckoutCommitReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { use crate::session::git::git_cli; let git_root = &self.git_root; let head_commit = &self.head_commit; if let Some(current) = crate::session::git::get_current_commit(git_root).await && current == *head_commit { return Ok(CheckoutCommitResponse { checked_out: true, stashed: false, fetched: false, error: None, }); } let mut stashed = false; if self.stash_if_dirty { let status = git_cli(git_root, &["status", "--porcelain"]).await; if let Ok(output) = &status && !output.trim().is_empty() { let msg = format!("auto-stash before checkout {head_commit}"); if git_cli(git_root, &["stash", "push", "-m", &msg]) .await .is_ok() { stashed = true; } } } match git_cli(git_root, &["checkout", head_commit]).await { Ok(_) => Ok(CheckoutCommitResponse { checked_out: true, stashed, fetched: false, error: None, }), Err(_) => { let _ = git_cli(git_root, &["fetch", "origin"]).await; match git_cli(git_root, &["checkout", head_commit]).await { Ok(_) => Ok(CheckoutCommitResponse { checked_out: true, stashed, fetched: true, error: None, }), Err(e) => { if stashed { let _ = git_cli(git_root, &["stash", "pop"]).await; } Ok(CheckoutCommitResponse { checked_out: false, stashed: false, fetched: true, error: Some(e.to_string()), }) } } } } } } workspace_rpc!( PrepareWorktreeFromWorktreeReq, "workspace.prepare_worktree_from_worktree", PrepareWorktreeFromWorktreeResponse ); #[async_trait] impl WorkspaceOp for PrepareWorktreeFromWorktreeReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let result = crate::worktree::prepare_worktree_from_worktree(&self.inner).await; match result.response { Ok(resp) => Ok(PrepareWorktreeFromWorktreeResponse { spawn_task: result.spawn_task, response: Some( serde_json::to_value(&resp) .map_err(|e| WorkspaceError::Internal(e.to_string()))?, ), error: None, }), Err(e) => Ok(PrepareWorktreeFromWorktreeResponse { spawn_task: false, response: None, error: Some(e.to_string()), }), } } } #[async_trait] impl WorkspaceOp for CreateWorktreeFromWorktreeSyncReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let req = crate::worktree::CreateWorktreeFromWorktreeRequest::from(self.inner.clone()); crate::worktree::create_worktree_from_worktree_sync(&req) .await .map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeDbRebuildReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let report = crate::worktree::worktree_db_rebuild() .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeDbPathReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let path = crate::worktree::worktree_db_path() .map_err(|e| WorkspaceError::Internal(e.to_string()))?; Ok(WorktreeDbPathResponse { path: Some(path.display().to_string()), }) } } #[async_trait] impl WorkspaceOp for HunkSingleActionReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let hunk_id = kigi_hunk_tracker::types::HunkId::from_string(self.action.hunk_id.clone()); let hunk_action = tracker_action(self.action.action); session_tracker(ws, session_id)? .hunk_action(hunk_id, hunk_action) .await .map_err(|e| WorkspaceError::HunkActionFailed(e.to_string()))?; Ok(HunkActionResponse {}) } } #[async_trait] impl WorkspaceOp for HunkFileActionReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let hunk_action = tracker_action(self.action); let affected = session_tracker(ws, session_id)? .file_action(std::path::PathBuf::from(&self.path), hunk_action) .await .map_err(|e| WorkspaceError::HunkActionFailed(e.to_string()))?; Ok(BulkHunkActionResponse { affected: affected.iter().map(|id| id.as_str().to_owned()).collect(), }) } } #[async_trait] impl WorkspaceOp for HunkTurnActionReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let hunk_action = tracker_action(self.action); let affected = session_tracker(ws, session_id)? .turn_action(self.prompt_index, hunk_action) .await .map_err(|e| WorkspaceError::HunkActionFailed(e.to_string()))?; Ok(BulkHunkActionResponse { affected: affected.iter().map(|id| id.as_str().to_owned()).collect(), }) } } #[async_trait] impl WorkspaceOp for HunkAllActionReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let hunk_action = tracker_action(self.action); let affected = session_tracker(ws, session_id)? .all_action(hunk_action) .await .map_err(|e| WorkspaceError::HunkActionFailed(e.to_string()))?; Ok(BulkHunkActionResponse { affected: affected.iter().map(|id| id.as_str().to_owned()).collect(), }) } } #[async_trait] impl WorkspaceOp for HunkGetAllFileContentsReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { Ok(session_tracker(ws, session_id)? .get_all_file_contents() .await .into_iter() .map(file_content_entry_to_wire) .collect()) } } #[async_trait] impl WorkspaceOp for HunkGetSessionSummaryReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { Ok(session_summary_to_wire( session_tracker(ws, session_id)?.get_session_summary().await, )) } } #[async_trait] impl WorkspaceOp for HunkGetAllHunksReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let hunks = session_tracker(ws, session_id)?.get_all_hunks().await; Ok(hunks.iter().map(|arc| hunk_to_wire(arc)).collect()) } } #[async_trait] impl WorkspaceOp for HunkGetStagedFilesReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let files = session_tracker(ws, session_id)?.get_staged_files().await; Ok(files .iter() .map(|p| p.to_string_lossy().to_string()) .collect()) } } #[async_trait] impl WorkspaceOp for HunkGetFilteredHunksReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let all_hunks = session_tracker(ws, session_id)?.get_all_hunks().await; let filtered: Vec = all_hunks .into_iter() .filter(|h| { if let Some(ref path) = self.path && !h.path.to_string_lossy().contains(path.as_str()) { return false; } if let Some(ref source) = self.source { let source_str = format!("{:?}", h.source); if !source_str.to_lowercase().contains(&source.to_lowercase()) { return false; } } true }) .map(|arc| hunk_to_wire(&arc)) .collect(); let total = filtered.len(); Ok(FilteredHunksResponse { hunks: filtered, total, }) } } #[async_trait] impl WorkspaceOp for HunkGetFileSummariesReq { async fn execute( &self, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let all_hunks = session_tracker(ws, session_id)?.get_all_hunks().await; let mut file_map: std::collections::HashMap = std::collections::HashMap::new(); for h in &all_hunks { let path_str = h.path.to_string_lossy().to_string(); let is_agent = matches!( h.source, kigi_hunk_tracker::types::HunkSource::AgentEdit { .. } ); let entry = file_map.entry(path_str).or_insert((0, false)); entry.0 += 1; if is_agent { entry.1 = true; } } Ok(file_map .into_iter() .map(|(path, (hunk_count, is_agent_file))| FileSummary { path, hunk_count, is_agent_file, }) .collect()) } } #[async_trait] impl WorkspaceOp for FuzzyOpenReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let search_id = ws .fuzzy_open( self.root.as_deref(), self.request_id.clone(), self.hidden, self.session_id.clone(), self.target_client_id.clone(), ) .await; Ok(search_id) } } #[async_trait] impl WorkspaceOp for FuzzyChangeReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let Some((min_generation, has_query, query_version)) = ws .fuzzy_change(&self.search_id, &self.query, self.dirs_only) .await else { return Ok(false); }; let ws = ws.clone(); let search_id = self.search_id.clone(); let limit = self.limit.unwrap_or(100); tokio::spawn(async move { ws.run_fuzzy_notifications(search_id, min_generation, has_query, query_version, limit) .await; }); Ok(true) } } #[async_trait] impl WorkspaceOp for FuzzyCloseReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { Ok(ws.fuzzy_close(&self.search_id).await) } } #[async_trait] impl WorkspaceOp for ContentSearchRequest { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let cwd = match &self.cwd { Some(c) => c.clone(), None => ws.root_cwd()?, }; let context_id = self .context_id .clone() .unwrap_or_else(|| "agent".to_string()); let params: crate::file_system::ContentSearchParams = self.clone().into(); ws.run_content_search(cwd, context_id, params).await } } /// Convert the heavy `HookRegistry` to its wire mirror via a serde round-trip. /// The registry's `hooks` map is private, so reconstructing field-by-field /// isn't possible; the round-trip is faithful because the wire type mirrors the /// serde shape exactly (the compiled `matcher` is `#[serde(skip)]` either way). fn hook_registry_to_wire( registry: &kigi_hooks::discovery::HookRegistry, ) -> WorkspaceResult { let value = serde_json::to_value(registry).map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::from_value(value).map_err(|e| WorkspaceError::Internal(e.to_string())) } /// Inverse of [`hook_registry_to_wire`]. The compiled `matcher` is absent from /// the wire (and from this result); callers recompile it via /// `HookRegistry::recompile_matchers`, exactly as the proxy path already did. fn wire_to_hook_registry( wire: &HookRegistryWire, ) -> WorkspaceResult { let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::from_value(value).map_err(|e| WorkspaceError::Internal(e.to_string())) } #[async_trait] impl WorkspaceOp for HookRegistryReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { hook_registry_to_wire(&ws.hook_registry()) } } /// Resolve the index root for a code-nav op. Prefers the explicit per-session /// `root` (the cwd the client sends per window), else the workspace root. /// Without this, code nav in a non-primary window would query the launch /// directory's index instead of the session's own repo. fn index_root_for( ws: &WorkspaceHandle, root: Option<&std::path::Path>, ) -> WorkspaceResult { let cwd = match root { Some(r) => r.to_path_buf(), None => ws.root_cwd()?, }; Ok(crate::session::git::find_git_root_from_path(&cwd).unwrap_or(cwd)) } fn resolve_index_for_workspace( ws: &WorkspaceHandle, root: Option<&std::path::Path>, ) -> WorkspaceResult<( std::sync::Arc, std::path::PathBuf, )> { let index_root = index_root_for(ws, root)?; let (handle, _was_new) = ws.get_or_create_codebase_index(index_root.clone()); Ok((handle, index_root)) } #[async_trait] impl WorkspaceOp for CodeGotoDefinitionReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let (handle, _root) = resolve_index_for_workspace(ws, self.root.as_deref())?; let result = handle .goto_definition(std::path::PathBuf::from(&self.file), self.line, self.col) .await .map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?; Ok(query_result_to_response(result)) } } #[async_trait] impl WorkspaceOp for CodeGotoReferencesReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let (handle, _root) = resolve_index_for_workspace(ws, self.root.as_deref())?; let result = handle .goto_references( std::path::PathBuf::from(&self.file), self.line, self.col, self.include_definition, ) .await .map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?; Ok(query_result_to_response(result)) } } #[async_trait] impl WorkspaceOp for CodeFindDefinitionsReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let (handle, _root) = resolve_index_for_workspace(ws, self.root.as_deref())?; let result = handle .find_definitions( self.symbol.clone(), self.context_file.as_ref().map(std::path::PathBuf::from), ) .await .map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?; Ok(symbol_locations_to_response(result)) } } #[async_trait] impl WorkspaceOp for CodeFindReferencesReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let (handle, _root) = resolve_index_for_workspace(ws, self.root.as_deref())?; let result = handle .find_references( self.symbol.clone(), self.context_file.as_ref().map(std::path::PathBuf::from), ) .await .map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?; Ok(symbol_locations_to_response(result)) } } #[async_trait] impl WorkspaceOp for CodeIndexStatusReq { async fn execute( &self, ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let index_root = index_root_for(ws, self.root.as_deref())?; let handle = ws.get_codebase_index(&index_root); match handle { Some(h) => { let file_count = h.get_file_count(); let stats = h.get_stats().map(|s| CodeIndexStats { files: s.files, definitions: s.definitions, references: s.references, }); Ok(CodeIndexStatusResponse { active: true, file_count, stats, }) } None => Ok(CodeIndexStatusResponse { active: false, file_count: None, stats: None, }), } } } fn query_result_to_response( result: Result, ) -> CodeNavResponse { match result { Ok(qr) => CodeNavResponse { locations: qr .locations .into_iter() .map(|loc| CodeNavLocation { path: loc.path, line: loc.line, symbol: loc.matched_symbol, }) .collect(), }, Err(_) => CodeNavResponse { locations: vec![] }, } } fn symbol_locations_to_response( locations: Vec, ) -> CodeNavResponse { CodeNavResponse { locations: locations .into_iter() .map(|loc| CodeNavLocation { path: loc.path, line: loc.line, symbol: loc.matched_symbol, }) .collect(), } } #[async_trait] impl WorkspaceOp for CreateWorktreeRequest { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let result = crate::worktree::prepare_worktree_creation(self).await; match result.response { Ok(resp) => { serde_json::to_value(resp).map_err(|e| WorkspaceError::Internal(e.to_string())) } Err(e) => Err(WorkspaceError::Internal(e.to_string())), } } } #[async_trait] impl WorkspaceOp for RemoveWorktreeRequest { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let copy_ctx = crate::worktree::BackgroundCopyContext::new(); let result = crate::worktree::remove_worktree(self, ©_ctx) .await .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for ApplyWorktreeRequest { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let result = crate::worktree::apply_worktree(self) .await .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeListReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let records = crate::worktree::list_worktrees(self.repo.as_deref(), &self.types, self.include_all) .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(records).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeShowReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let record = crate::worktree::show_worktree(&self.id_or_path) .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(record).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeGcReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let (dry_run, max_age_secs, force) = (self.dry_run, self.max_age_secs, self.force); let report = tokio::task::spawn_blocking(move || { crate::worktree::gc_worktrees_mgmt(dry_run, max_age_secs, force) }) .await .map_err(|e| WorkspaceError::Internal(e.to_string()))? .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string())) } } #[async_trait] impl WorkspaceOp for WorktreeDbStatsReq { async fn execute( &self, _ws: &WorkspaceHandle, _session_id: Option<&str>, ) -> WorkspaceResult { let stats = crate::worktree::worktree_db_stats() .map_err(|e| WorkspaceError::Internal(e.to_string()))?; serde_json::to_value(stats).map_err(|e| WorkspaceError::Internal(e.to_string())) } } /// Dual-mode workspace operations handle. /// /// Wraps a [`WorkspaceHandle`]. Extensions dispatch through the handle; /// tool calls dispatch through the workspace session's /// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset). /// Call [`bind_local_session`](Self::bind_local_session) after building /// the agent to install the toolset on the workspace session. #[derive(Clone)] pub enum WorkspaceOps { /// Local in-process mode — extensions through the handle, tool calls /// through the workspace session's toolset. Local { handle: WorkspaceHandle }, } impl WorkspaceOps { /// Construct a local-mode ops handle. /// /// Extensions dispatch through the handle immediately. Tool calls /// require a workspace session — call [`bind_local_session`](Self::bind_local_session) /// after building the agent to install the toolset. pub fn local(handle: WorkspaceHandle) -> Self { Self::Local { handle } } /// Access the underlying workspace handle. pub fn workspace_handle(&self) -> Option<&WorkspaceHandle> { match self { Self::Local { handle } => Some(handle), } } /// Create the workspace session and bind the agent's toolset for local mode. /// /// Creates the session (if absent) reusing the agent's per-session /// `hunk_tracker` rooted at `cwd`, so workspace-routed hunk queries resolve /// the same tracker the agent feeds rather than a duplicate rooted at the /// launch directory. Then replaces the session's toolset. `cwd` and /// `hunk_tracker` are only used on first create; a re-bind (e.g. after an /// agent rebuild) just replaces the toolset. /// /// The installed toolset keeps the shell's own terminal backend; the /// session-owned backend minted at create stays idle and is what /// `drop_session`/evict cancel — deliberately never adopted from the /// external toolset, or teardown would SIGKILL a backend the shell shares. /// /// No-op in proxy mode (the workspace server owns sessions). pub fn bind_local_session( &self, session_id: &str, cwd: std::path::PathBuf, hunk_tracker: kigi_hunk_tracker::HunkTrackerHandle, toolset: Arc, viewer_ctx: Option, ) -> WorkspaceResult<()> { let Self::Local { handle } = self; if handle.session(session_id).is_none() { handle.create_session_with_tracker_and_viewer_ctx( session_id, cwd, hunk_tracker, None, crate::capability::CapabilityMode::All, viewer_ctx, false, )?; } let session = handle .session(session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(session_id.to_owned()))?; session.replace(session.effective_tool_config(), toolset); Ok(()) } /// Release the workspace session. No-op in proxy mode. pub fn end_local_session(&self, session_id: &str) { let Self::Local { handle } = self; handle.on_session_ended(session_id); if let Err(e) = handle.drop_session(session_id, session_id) { tracing::debug!( % session_id, error = % e, "end_local_session: drop_session failed (expected if never bound)" ); } } pub async fn on_before_turn( &self, session_id: &str, payload: &kigi_tool_protocol::turn_hook::BeforeTurnPayload, ) { let Self::Local { handle } = self; handle.on_before_turn(session_id, payload).await; } pub async fn on_after_turn( &self, session_id: &str, payload: &kigi_tool_protocol::turn_hook::AfterTurnPayload, ) { let Self::Local { handle } = self; handle.on_after_turn(session_id, payload).await; } /// Dispatch a typed operation: calls `op.execute(handle, session_id)`. pub async fn dispatch( &self, op: &Op, session_id: Option<&str>, ) -> WorkspaceResult { tracing::debug!(method = Op::METHOD, "WorkspaceOps::dispatch"); let Self::Local { handle } = self; op.execute(handle, session_id).await } /// Get git status with configurable output format. /// /// `GitStatusExtReq` implements `WorkspaceOp`, so this is dispatched /// (local execute or proxy RPC) rather than being proxy-only. /// /// Use `format: GitStatusFormat::Prompt` for compact JSON string output /// (the replacement for the deprecated `git_status()` method). /// Use `format: GitStatusFormat::Structured` (default) for structured /// `GitStatusData` output. pub async fn git_status_ext( &self, req: &GitStatusExtReq, ) -> WorkspaceResult { self.dispatch(req, None).await } pub async fn hook_registry(&self) -> WorkspaceResult { let wire = self.dispatch(&HookRegistryReq {}, None).await?; wire_to_hook_registry(&wire) } /// Dispatch a tool call through the workspace session's /// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset) /// (in-process). Requires `session_id` to look up the session. pub async fn call_tool( &self, name: &str, args: Value, call_id: &str, session_id: Option<&str>, ) -> Result { let Self::Local { handle } = self; let session_id = session_id.ok_or_else(|| { kigi_tool_runtime::ToolError::custom( "missing_session", "session_id required for local tool dispatch", ) })?; let session = handle.session(session_id).ok_or_else(|| { kigi_tool_runtime::ToolError::custom( "session_not_found", format!( "workspace session not found: {session_id} \ — call bind_local_session() first" ), ) })?; session.toolset().call(name, args, call_id, None).await } } #[cfg(any(test, feature = "test-support"))] impl WorkspaceOps { /// Test variant backed by a temp dir. /// /// Supports extension dispatch (`dispatch()`). Tool calls via /// `call_tool()` require a workspace session — call /// `bind_local_session()` with a test toolset first. pub fn for_test() -> Self { Self::Local { handle: WorkspaceHandle::for_test(), } } /// Like [`Self::for_test`] but rooted at `root`. pub fn for_test_in(root: &std::path::Path) -> Self { Self::Local { handle: WorkspaceHandle::for_test_in(root), } } } #[cfg(test)] mod tests { use super::*; /// Drift pin for these workspace methods' `workspace.*` wire names. The /// request types are defined in `kigi-workspace-types` (and re-exported /// from this module for existing call sites) so the gateway's typed dispatch /// in `workspace_typed/` can consume them without depending on this crate; /// this test pins the `::METHOD` strings so a rename can't silently change /// the wire contract. #[test] fn pinned_workspace_method_wire_names() { assert_eq!(HookRegistryReq::METHOD, "workspace.hook_registry"); assert_eq!(HunkGetAllHunksReq::METHOD, "workspace.get_all_hunks"); assert_eq!( HunkGetSessionSummaryReq::METHOD, "workspace.get_session_summary" ); assert_eq!( HunkGetAllFileContentsReq::METHOD, "workspace.hunk_get_all_file_contents" ); assert_eq!( HunkGetFilteredHunksReq::METHOD, "workspace.hunk_get_filtered_hunks" ); assert_eq!( CreateWorktreeFromWorktreeSyncReq::METHOD, "workspace.worktree_create_from_worktree_sync" ); } /// The reported bug: every window's git queries ran against the workspace /// launch directory. `git_op_cwd` must return the per-session repo the /// client sends, and only fall back to the workspace root when none is given. #[test] fn git_op_cwd_uses_explicit_git_root_per_window() { let ops = WorkspaceOps::for_test(); let WorkspaceOps::Local { handle } = &ops; let workspace_root = handle.root_cwd().unwrap(); let window_a = std::path::PathBuf::from("/repos/xai-main"); let window_b = std::path::PathBuf::from("/repos/xai-main-2"); assert_eq!( git_op_cwd(handle, &Some(window_a.clone())).unwrap(), window_a ); assert_eq!( git_op_cwd(handle, &Some(window_b.clone())).unwrap(), window_b ); assert_eq!(git_op_cwd(handle, &None).unwrap(), workspace_root); } /// Regression: a long-lived (leader) workspace must reclaim the per-session /// `FinalizedToolset` — and the MCP tools / `McpState` / `events.jsonl` /// `EventWriter` it transitively pins — when a session ends. /// `bind_local_session` installs the toolset on a leader-level workspace /// session; without `end_local_session` that session (and everything it /// holds) leaks for the life of the process. #[tokio::test] async fn end_local_session_drops_bound_toolset() { let ops = WorkspaceOps::for_test(); let WorkspaceOps::Local { handle } = &ops; let sid = "sess-teardown"; let toolset = std::sync::Arc::new(kigi_tools::registry::types::FinalizedToolset::empty_for_test()); let weak = std::sync::Arc::downgrade(&toolset); ops.bind_local_session( sid, handle.root_cwd().unwrap(), kigi_hunk_tracker::HunkTrackerHandle::noop(), toolset, None, ) .expect("bind should succeed"); assert!(handle.session(sid).is_some(), "session must be bound"); assert!( weak.upgrade().is_some(), "workspace session must hold the toolset" ); ops.end_local_session(sid); assert!( handle.session(sid).is_none(), "end_local_session must remove the workspace session" ); assert!( weak.upgrade().is_none(), "end_local_session must drop the toolset (no leaked holder)" ); } /// Round-trip serde for HunkActionResponse. #[test] fn hunk_action_response_round_trip() { let resp = HunkActionResponse {}; let json = serde_json::to_value(&resp).unwrap(); let recovered: HunkActionResponse = serde_json::from_value(json).unwrap(); assert_eq!(format!("{recovered:?}"), "HunkActionResponse"); } /// Round-trip serde for BulkHunkActionResponse. #[test] fn bulk_hunk_action_response_round_trip() { let resp = BulkHunkActionResponse { affected: vec!["hunk-1".into(), "hunk-2".into()], }; let json = serde_json::to_value(&resp).unwrap(); let recovered: BulkHunkActionResponse = serde_json::from_value(json).unwrap(); assert_eq!(recovered.affected, vec!["hunk-1", "hunk-2"]); } /// Round-trip serde for FilteredHunksResponse (empty). #[test] fn filtered_hunks_response_round_trip_empty() { let resp = FilteredHunksResponse { hunks: vec![], total: 0, }; let json = serde_json::to_value(&resp).unwrap(); let recovered: FilteredHunksResponse = serde_json::from_value(json).unwrap(); assert!(recovered.hunks.is_empty()); assert_eq!(recovered.total, 0); } /// Round-trip serde for FileSummary. #[test] fn file_summary_round_trip() { let summary = FileSummary { path: "src/main.rs".into(), hunk_count: 3, is_agent_file: true, }; let json = serde_json::to_value(&summary).unwrap(); let recovered: FileSummary = serde_json::from_value(json).unwrap(); assert_eq!(recovered.path, "src/main.rs"); assert_eq!(recovered.hunk_count, 3); assert!(recovered.is_agent_file); } /// BulkHunkActionResponse default is empty. #[test] fn bulk_hunk_action_response_default() { let resp = BulkHunkActionResponse::default(); assert!(resp.affected.is_empty()); } /// FilteredHunksResponse default is empty. #[test] fn filtered_hunks_response_default() { let resp = FilteredHunksResponse::default(); assert!(resp.hunks.is_empty()); assert_eq!(resp.total, 0); } /// A `Hunk`'s wire mirror serializes byte-for-byte like the heavy type. #[test] fn hunk_to_wire_serializes_identically() { use kigi_hunk_tracker::types::{Hunk, HunkSource}; let mut hunk = Hunk::file_created( std::path::PathBuf::from("/repo/a.rs"), "new\n".to_string(), HunkSource::AgentEdit { prompt_index: 2 }, ); hunk.old_text = Some("old\n".to_string()); hunk.patch = Some("@@ -1 +1 @@\n".to_string()); hunk.selected = true; assert_eq!( serde_json::to_value(&hunk).unwrap(), serde_json::to_value(hunk_to_wire(&hunk)).unwrap() ); } /// A `FileContentEntry`'s wire mirror serializes identically (incl. the /// `skip_serializing_if` handling on absent baseline content). #[test] fn file_content_entry_to_wire_serializes_identically() { use kigi_hunk_tracker::FileContentEntry; use kigi_hunk_tracker::types::FileContentView; let entry = FileContentEntry { path: std::path::PathBuf::from("/repo/a.rs"), baseline: FileContentView::missing(), current: FileContentView::full("x\n".to_string()), is_agent_file: true, staged: false, }; assert_eq!( serde_json::to_value(&entry).unwrap(), serde_json::to_value(file_content_entry_to_wire(entry)).unwrap() ); } /// A `SessionSummary` (with a turn carrying a hunk) mirrors identically. #[test] fn session_summary_to_wire_serializes_identically() { use kigi_hunk_tracker::SessionSummary; use kigi_hunk_tracker::types::{Hunk, HunkSource, TurnSummary}; use std::sync::Arc; let hunk = Hunk::file_created( std::path::PathBuf::from("/repo/a.rs"), "x\n".to_string(), HunkSource::AgentEdit { prompt_index: 1 }, ); let summary = SessionSummary { files_modified: 2, pending_hunks: 1, turns: vec![TurnSummary { prompt_index: 1, files: vec![std::path::PathBuf::from("/repo/a.rs")], pending_hunks: vec![Arc::new(hunk)], lines_added: 1, lines_removed: 0, }], ..Default::default() }; assert_eq!( serde_json::to_value(&summary).unwrap(), serde_json::to_value(session_summary_to_wire(summary)).unwrap() ); } /// `HookRegistry` round-trips through the wire mirror in both directions /// (heavy → wire serializes identically; wire → heavy is the inverse). #[test] fn hook_registry_wire_round_trip_both_directions() { let spec = kigi_hooks::config::HookSpec { name: "global/safety".to_string(), event: kigi_hooks::event::HookEventName::PreToolUse, handler_type: "command".to_string(), configured_matcher: Some("Bash".to_string()), matcher: None, enabled: true, command: Some(std::path::PathBuf::from("/bin/check.sh")), command_raw: Some("${X}/check.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::path::PathBuf::from("/home/u/.kigi/hooks"), extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]), }; let mut registry = kigi_hooks::discovery::HookRegistry::default(); registry.append_specs(vec![spec]); let wire = hook_registry_to_wire(®istry).expect("heavy → wire"); assert_eq!( serde_json::to_value(®istry).unwrap(), serde_json::to_value(&wire).unwrap() ); let back = wire_to_hook_registry(&wire).expect("wire → heavy"); assert_eq!( serde_json::to_value(&back).unwrap(), serde_json::to_value(®istry).unwrap() ); } /// Compile-time drift guard: the lean `HookEventNameWire` can't depend on /// upstream `HookEventName`, and `hook_registry_to_wire`/`wire_to_hook_registry` /// only couple them at runtime. This exhaustive `match` (no wildcard) fails to /// compile if upstream adds a variant, forcing the wire mirror to be updated /// before the serde round-trip could silently start erroring. The assertion /// also pins that each variant's serialized key is byte-identical on both sides. #[test] fn hook_event_name_wire_covers_all_upstream_variants() { use kigi_hooks::event::HookEventName as E; fn to_wire(e: E) -> HookEventNameWire { match e { E::SessionStart => HookEventNameWire::SessionStart, E::SessionEnd => HookEventNameWire::SessionEnd, E::Stop => HookEventNameWire::Stop, E::StopFailure => HookEventNameWire::StopFailure, E::PreToolUse => HookEventNameWire::PreToolUse, E::PostToolUse => HookEventNameWire::PostToolUse, E::PostToolUseFailure => HookEventNameWire::PostToolUseFailure, E::PermissionDenied => HookEventNameWire::PermissionDenied, E::UserPromptSubmit => HookEventNameWire::UserPromptSubmit, E::Notification => HookEventNameWire::Notification, E::SubagentStart => HookEventNameWire::SubagentStart, E::SubagentStop => HookEventNameWire::SubagentStop, E::SubagentEnd => HookEventNameWire::SubagentEnd, E::PreCompact => HookEventNameWire::PreCompact, E::PostCompact => HookEventNameWire::PostCompact, } } for e in [ E::SessionStart, E::SessionEnd, E::Stop, E::StopFailure, E::PreToolUse, E::PostToolUse, E::PostToolUseFailure, E::PermissionDenied, E::UserPromptSubmit, E::Notification, E::SubagentStart, E::SubagentStop, E::SubagentEnd, E::PreCompact, E::PostCompact, ] { assert_eq!( serde_json::to_value(e).unwrap(), serde_json::to_value(to_wire(e)).unwrap(), "wire key drifted for {e:?}" ); } } /// Compile-time drift guard for `HookSpecWire`, the struct analog of /// `hook_event_name_wire_covers_all_upstream_variants`. The lean types crate /// can't depend on `kigi-hooks`, and `hook_registry_to_wire` only couples /// the two via a serde round-trip, so a new serialized field on upstream /// `HookSpec` would otherwise be dropped on the wire silently. The exhaustive /// destructuring below (no `..`) fails to compile when upstream adds or renames /// a field, and rebuilding `HookSpecWire` from those bindings catches wire-side /// drift; the assertion pins that both serde shapes stay byte-identical. The /// compiled `matcher` is `#[serde(skip)]` and is the only field intentionally /// absent from the wire. #[test] fn hook_spec_wire_covers_all_upstream_fields() { use kigi_hooks::config::HookSpec; fn to_wire(spec: HookSpec) -> HookSpecWire { let HookSpec { name, event, handler_type, configured_matcher, matcher: _, enabled, command, command_raw, url, url_raw, timeout_ms, source_dir, extra_env, } = spec; let event = serde_json::from_value(serde_json::to_value(event).unwrap()).unwrap(); HookSpecWire { name, event, handler_type, configured_matcher, enabled, command, command_raw, url, url_raw, timeout_ms, source_dir, extra_env, } } let spec = HookSpec { name: "global/safety".to_string(), event: kigi_hooks::event::HookEventName::PreToolUse, handler_type: "command".to_string(), configured_matcher: Some("Bash".to_string()), matcher: None, enabled: true, command: Some(std::path::PathBuf::from("/bin/check.sh")), command_raw: Some("${X}/check.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::path::PathBuf::from("/home/u/.kigi/hooks"), extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]), }; assert_eq!( serde_json::to_value(&spec).unwrap(), serde_json::to_value(to_wire(spec.clone())).unwrap(), "HookSpecWire serde shape drifted from upstream HookSpec" ); } /// The worktree-fork request projects onto / rebuilds from its wire mirror; /// the two `#[serde(skip)]` runtime fields never ride the wire. #[test] fn create_worktree_from_worktree_request_wire_round_trip() { let req = crate::worktree::CreateWorktreeFromWorktreeRequest { source_worktree_path: "/src".to_string(), new_session_id: "s2".to_string(), copy_mode: crate::worktree::WorktreeCopyMode::Dirty, git_ref: Some("main".to_string()), worktree_type: Some(crate::worktree::WorktreeType::Linked), label: None, cancellation_token: None, resolved_dest_path: None, }; assert_eq!( serde_json::to_value(&req).unwrap(), serde_json::to_value(req.clone().into_wire()).unwrap() ); let back: crate::worktree::CreateWorktreeFromWorktreeRequest = req.into_wire().into(); assert_eq!(back.source_worktree_path, "/src"); assert!(back.cancellation_token.is_none()); assert!(back.resolved_dest_path.is_none()); } use crate::handle::tests::make_handle; #[tokio::test] async fn execute_hunk_get_all_file_contents_returns_empty_for_fresh_tracker() { let handle = make_handle(); let op_result = HunkGetAllFileContentsReq {} .execute(&handle, Some("main")) .await .expect("execute should succeed"); assert!(op_result.is_empty()); } #[tokio::test] async fn execute_hunk_get_session_summary_returns_value() { let handle = make_handle(); let op_result = HunkGetSessionSummaryReq {} .execute(&handle, Some("main")) .await .expect("execute should succeed"); let json = serde_json::to_value(&op_result).unwrap(); assert!(json.is_object() || json.is_null()); } #[tokio::test] async fn execute_hunk_get_all_hunks_returns_empty_for_fresh_tracker() { let handle = make_handle(); let op_result = HunkGetAllHunksReq {} .execute(&handle, Some("main")) .await .expect("execute should succeed"); assert!(op_result.is_empty()); } #[tokio::test] async fn execute_hunk_get_staged_files_returns_empty_for_fresh_tracker() { let handle = make_handle(); let op_result = HunkGetStagedFilesReq {} .execute(&handle, Some("main")) .await .expect("execute should succeed"); assert!(op_result.is_empty()); } #[tokio::test] async fn execute_hunk_get_filtered_hunks_returns_empty_for_fresh_tracker() { let handle = make_handle(); let op_result = HunkGetFilteredHunksReq::default() .execute(&handle, Some("main")) .await .expect("execute should succeed"); assert!(op_result.hunks.is_empty()); assert_eq!(op_result.total, 0); } #[tokio::test] async fn execute_hunk_get_file_summaries_returns_empty_for_fresh_tracker() { let handle = make_handle(); let op_result = HunkGetFileSummariesReq {} .execute(&handle, Some("main")) .await .expect("execute should succeed"); assert!(op_result.is_empty()); } fn test_fuzzy_open_req() -> FuzzyOpenReq { FuzzyOpenReq { root: None, request_id: None, hidden: false, session_id: None, target_client_id: crate::file_system::TargetClientId::None, } } #[tokio::test] async fn execute_fuzzy_open_returns_search_id() { let handle = make_handle(); let search_id = test_fuzzy_open_req() .execute(&handle, None) .await .expect("execute should succeed"); assert!(!search_id.is_empty()); } #[tokio::test] async fn execute_fuzzy_close_nonexistent_returns_false() { let handle = make_handle(); let closed = FuzzyCloseReq { search_id: "nonexistent".into(), } .execute(&handle, None) .await .expect("execute should succeed"); assert!(!closed); } #[tokio::test] async fn execute_fuzzy_open_close_parity() { let handle = make_handle(); let search_id = test_fuzzy_open_req() .execute(&handle, None) .await .expect("open"); let closed = FuzzyCloseReq { search_id: search_id.clone(), } .execute(&handle, None) .await .expect("close"); assert!(closed, "search we just opened should close"); let closed_again = FuzzyCloseReq { search_id } .execute(&handle, None) .await .expect("close2"); assert!(!closed_again, "second close should return false"); } #[tokio::test] async fn execute_fuzzy_change_nonexistent_returns_false() { let handle = make_handle(); let found = FuzzyChangeReq { search_id: "nonexistent".into(), query: "test".into(), dirs_only: false, limit: None, } .execute(&handle, None) .await .expect("execute should succeed"); assert!(!found); } /// PutFileEntry serde round-trip with defaults. #[test] fn put_file_entry_defaults() { let json = serde_json::json!( { "path" : "src/main.rs", "content" : "fn main() {}" } ); let entry: PutFileEntry = serde_json::from_value(json).unwrap(); assert_eq!(entry.path, "src/main.rs"); assert_eq!(entry.content, "fn main() {}"); assert!(entry.create_dirs, "create_dirs should default to true"); assert!(!entry.append, "append should default to false"); } /// PutFilesReq round-trip. #[test] fn put_files_req_round_trip() { let req = PutFilesReq { files: vec![PutFileEntry { path: "a.txt".into(), content: "hello".into(), create_dirs: false, append: true, }], }; let json = serde_json::to_value(&req).unwrap(); let recovered: PutFilesReq = serde_json::from_value(json).unwrap(); assert_eq!(recovered.files.len(), 1); assert_eq!(recovered.files[0].path, "a.txt"); assert!(!recovered.files[0].create_dirs); assert!(recovered.files[0].append); } /// PutFilesReq METHOD constant. #[test] fn put_files_req_method() { assert_eq!(::METHOD, "workspace.put_files"); } /// PutFileResult serialization skips None fields. #[test] fn put_file_result_skip_none() { let result = PutFileResult { path: "a.txt".into(), ok: true, error: None, hash: Some("abc123".into()), }; let json = serde_json::to_value(&result).unwrap(); assert!(!json.as_object().unwrap().contains_key("error")); assert_eq!(json["hash"], "abc123"); } /// GetFileEntry serde round-trip with defaults. #[test] fn get_file_entry_defaults() { let json = serde_json::json!({ "path" : "lib.rs" }); let entry: GetFileEntry = serde_json::from_value(json).unwrap(); assert_eq!(entry.path, "lib.rs"); assert!(entry.if_none_match.is_none()); assert!(entry.offset.is_none()); assert!(entry.length.is_none()); } /// GetFilesReq round-trip with all optional fields. #[test] fn get_files_req_round_trip() { let req = GetFilesReq { files: vec![GetFileEntry { path: "data.bin".into(), if_none_match: Some("sha256-abc".into()), offset: Some(100), length: Some(200), }], }; let json = serde_json::to_value(&req).unwrap(); let recovered: GetFilesReq = serde_json::from_value(json).unwrap(); assert_eq!( recovered.files[0].if_none_match.as_deref(), Some("sha256-abc") ); assert_eq!(recovered.files[0].offset, Some(100)); assert_eq!(recovered.files[0].length, Some(200)); } /// GetFilesReq METHOD constant. #[test] fn get_files_req_method() { assert_eq!(::METHOD, "workspace.get_files"); } /// GetFileResult serialization skips None fields, defaults matched to false. #[test] fn get_file_result_defaults_and_skip() { let json = serde_json::json!({ "path" : "a.txt", "exists" : true, }); let result: GetFileResult = serde_json::from_value(json).unwrap(); assert!(!result.matched, "matched should default to false"); assert!(result.content.is_none()); assert!(result.hash.is_none()); assert!(result.size.is_none()); assert!(result.error.is_none()); let serialized = serde_json::to_value(&result).unwrap(); let obj = serialized.as_object().unwrap(); assert!(!obj.contains_key("content")); assert!(!obj.contains_key("hash")); assert!(!obj.contains_key("size")); assert!(!obj.contains_key("error")); } /// PutFilesRes / GetFilesRes round-trip. #[test] fn put_get_files_res_round_trip() { let put_res = PutFilesRes { results: vec![PutFileResult { path: "x.rs".into(), ok: false, error: Some("permission denied".into()), hash: None, }], }; let json = serde_json::to_value(&put_res).unwrap(); let recovered: PutFilesRes = serde_json::from_value(json).unwrap(); assert!(!recovered.results[0].ok); assert_eq!( recovered.results[0].error.as_deref(), Some("permission denied") ); let get_res = GetFilesRes { results: vec![GetFileResult { path: "y.rs".into(), exists: true, content: Some("contents".into()), hash: Some("deadbeef".into()), matched: false, size: Some(8), error: None, }], }; let json = serde_json::to_value(&get_res).unwrap(); let recovered: GetFilesRes = serde_json::from_value(json).unwrap(); assert_eq!(recovered.results[0].size, Some(8)); assert_eq!(recovered.results[0].content.as_deref(), Some("contents")); } /// Code-nav must resolve its index at the per-session root the client /// sends, not the shared workspace root — otherwise a second window would /// query the first window's index. #[tokio::test] async fn index_root_for_uses_explicit_per_window_root() { let handle = make_handle(); let window_a = std::path::Path::new("/nonexistent/window-a"); let window_b = std::path::Path::new("/nonexistent/window-b"); assert_eq!(index_root_for(&handle, Some(window_a)).unwrap(), window_a); assert_eq!(index_root_for(&handle, Some(window_b)).unwrap(), window_b); assert_ne!(index_root_for(&handle, None).unwrap(), window_a); } }