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).
1955 lines
72 KiB
Rust
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, ©_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(®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!(<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);
|
|
}
|
|
}
|