Files
Kigi-CLI/crates/codegen/kigi-workspace/src/workspace_ops.rs
T
ZacharyZhang-NY fa75eb139a M2 audit: excise the Computer Hub stack — Kigi's last remote-cloud surface
Removed root-and-branch for the zero-egress guarantee (the hub was xAI's
remote-workspace/cloud-sandbox service):

- Crates deleted: kigi-computer-hub-core, kigi-computer-hub-sdk,
  kigi-computer-hub-mcp-adapter, kigi-workspace-client (hub-proxied
  workspace RPC client), and kigi-tracing (its sole network path was the
  OTLP gRPC exporter; zero consumers remained). kigi-tracing-macros
  (purely local) stays.
- kigi-workspace: every hub surface deleted — hub server/channel/auth,
  HITL-over-hub permissions, donation/metrics pumps, file upload RPCs,
  hub tool-snapshot merge (resolve pipeline is MCP-only now),
  WorkspaceOps::Proxy. Local worktrees, sessions, leader IPC, MCP, and
  the ACP permission prompt path are untouched; LocalRegistry re-homed
  into kigi-tool-runtime on the existing ToolDyn types so in-process
  tool dispatch is unchanged.
- kigi-shell: leader workspace-exposure control surface (incl. the
  wss://computer-hub... URL), [hub] config, ObservabilityBridge, hub
  WebSocket proxy, dead OTLP config knobs. ClientMode::Headless (never
  constructed) removed.
- kigi-tui/bin: hidden `kigi workspace` command removed (`kigi
  worktree` stays).
- Renames: --xai-api-base-url → --api-base-url / KIGI_API_BASE_URL /
  [endpoints] api_base_url (serde alias keeps old configs working; the
  flag feeds BYOK/custom-endpoint routing, not main inference);
  grok_version → kigi_version in inspect/models-cache/trace metadata
  (old caches self-heal via version-mismatch refetch).
- Dependency tree: dropped fastrace*, opentelemetry-otlp/http/proto,
  tokio-tungstenite from the workspace; fixed the 4 real useless_format
  violations the fastrace lint allowance was masking and removed the
  allowance.
- marketplaceAllowlist kept: it gates the LOCAL plugin-marketplace
  feature, not an xAI service.

Known §9 leftover (deliberate, for the M3 sweep): the BYOK default base
URL string. Gates: workspace check/clippy 0/0, fmt, deny ok; suites
green (workspace 1042, shell 4918, tui 6634, tools 2608, tool-runtime
47, mcp 154).
2026-07-17 22:34:10 -04:00

1955 lines
72 KiB
Rust

//! [`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<Self::Response>;
}
/// 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<crate::session::file_state::RewindPoint>;
}
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<kigi_hunk_tracker::HunkTrackerHandle> {
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<std::path::PathBuf>,
) -> WorkspaceResult<std::path::PathBuf> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
{
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
let all_hunks = session_tracker(ws, session_id)?.get_all_hunks().await;
let filtered: Vec<HunkWire> = 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<Self::Response> {
let all_hunks = session_tracker(ws, session_id)?.get_all_hunks().await;
let mut file_map: std::collections::HashMap<String, (usize, bool)> =
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<HookRegistryWire> {
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<kigi_hooks::discovery::HookRegistry> {
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<Self::Response> {
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<std::path::PathBuf> {
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<kigi_codebase_graph::IndexManagerHandle>,
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<kigi_codebase_graph::QueryResult, kigi_codebase_graph::QueryError>,
) -> 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<kigi_codebase_graph::SymbolLocation>,
) -> 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<Self::Response> {
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<Self::Response> {
let copy_ctx = crate::worktree::BackgroundCopyContext::new();
let result = crate::worktree::remove_worktree(self, &copy_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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<Self::Response> {
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<kigi_tools::registry::types::FinalizedToolset>,
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
) -> 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<Op: WorkspaceOp>(
&self,
op: &Op,
session_id: Option<&str>,
) -> WorkspaceResult<Op::Response> {
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<GitStatusExtResponse> {
self.dispatch(req, None).await
}
pub async fn hook_registry(&self) -> WorkspaceResult<kigi_hooks::discovery::HookRegistry> {
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<ToolRunResult, kigi_tool_runtime::ToolError> {
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(&registry).expect("heavy → wire");
assert_eq!(
serde_json::to_value(&registry).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(&registry).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!(<PutFilesReq as WorkspaceRpc>::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!(<GetFilesReq as WorkspaceRpc>::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);
}
}