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).
This commit is contained in:
@@ -1,30 +1,22 @@
|
||||
//! [`WorkspaceOps`] — dual-mode workspace operations handle.
|
||||
//! [`WorkspaceOps`] — workspace operations handle.
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! - **`Local`** — 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.
|
||||
//!
|
||||
//! - **`Proxy`** — everything routes through hub WebSocket to a remote
|
||||
//! workspace server.
|
||||
//! 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 RPC method has a corresponding request struct that implements
|
||||
//! Each operation has a corresponding request struct that implements
|
||||
//! [`WorkspaceRpc`]. The struct carries a `METHOD` constant and derives
|
||||
//! `Serialize + Deserialize`. Both the proxy client (`WorkspaceOps`) and
|
||||
//! the server (`WorkspaceRpcHandler::dispatch`) use the same struct —
|
||||
//! add/rename a field and the compiler catches both sides.
|
||||
//! `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_computer_hub_sdk::ToolHarness;
|
||||
use kigi_tools::types::output::ToolRunResult;
|
||||
use kigi_workspace_client::{WorkspaceClient, is_transport_fatal};
|
||||
pub use kigi_workspace_types::rpc::WorkspaceRpc;
|
||||
pub use kigi_workspace_types::rpc::agents_md::DiscoverAgentsMdReq;
|
||||
pub use kigi_workspace_types::rpc::code_nav::{
|
||||
@@ -32,9 +24,8 @@ pub use kigi_workspace_types::rpc::code_nav::{
|
||||
CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse,
|
||||
};
|
||||
pub use kigi_workspace_types::rpc::fs::{
|
||||
ClientFsListNode, ClientFsListReq, ClientFsListRes, ClientFsReadFileReq, ClientFsReadFileRes,
|
||||
ClientFsStatReq, ClientFsStatRes, GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes,
|
||||
PutFileEntry, PutFileResult, PutFilesReq, PutFilesRes,
|
||||
GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes, PutFileEntry, PutFileResult,
|
||||
PutFilesReq, PutFilesRes,
|
||||
};
|
||||
pub use kigi_workspace_types::rpc::git::{
|
||||
BinaryFileInfoData, CheckoutCommitResponse, CommitWithPatchData, DetectVcsKindReq,
|
||||
@@ -69,7 +60,6 @@ use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
/// Implements [`WorkspaceRpc`] for request types whose responses
|
||||
/// reference crate-internal types and so cannot live in the types crate.
|
||||
macro_rules! workspace_rpc {
|
||||
@@ -214,7 +204,7 @@ fn session_tracker(
|
||||
session_id: Option<&str>,
|
||||
) -> WorkspaceResult<kigi_hunk_tracker::HunkTrackerHandle> {
|
||||
let sid = session_id
|
||||
.ok_or_else(|| WorkspaceError::HubError("per-session hunk op requires a session".into()))?;
|
||||
.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()))?;
|
||||
@@ -251,13 +241,13 @@ impl WorkspaceOp for GitStatusExtReq {
|
||||
self.include_patches,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.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::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(GitStatusExtResponse::prompt(result))
|
||||
}
|
||||
}
|
||||
@@ -273,7 +263,7 @@ impl WorkspaceOp for GitFilesReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::read_files(&cwd, &self.paths, &self.version)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -294,7 +284,7 @@ impl WorkspaceOp for GitDiffReq {
|
||||
self.merge_base,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -307,7 +297,7 @@ impl WorkspaceOp for GitStageReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stage(&cwd, self.paths.clone())
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -320,7 +310,7 @@ impl WorkspaceOp for GitStageContentReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stage_content(&cwd, &self.path, &self.content)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -333,7 +323,7 @@ impl WorkspaceOp for GitUnstageReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::unstage(&cwd, self.paths.clone())
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -346,7 +336,7 @@ impl WorkspaceOp for GitDiscardReq {
|
||||
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::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -366,7 +356,7 @@ impl WorkspaceOp for GitCommitReq {
|
||||
self.sync,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -379,7 +369,7 @@ impl WorkspaceOp for GitCheckoutReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::checkout_branch(&cwd, &self.branch, self.create)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -392,7 +382,7 @@ impl WorkspaceOp for GitStashReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stash(&cwd, self.include_untracked)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -405,7 +395,7 @@ impl WorkspaceOp for GitInfoReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::git_info(&cwd)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -418,7 +408,7 @@ impl WorkspaceOp for GitBranchesReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::list_branches(&cwd)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -429,7 +419,7 @@ impl WorkspaceOp for GitCollectChangesReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
{
|
||||
return Err(WorkspaceError::HubError(
|
||||
return Err(WorkspaceError::Internal(
|
||||
"git collect changes is unavailable in this build".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -550,7 +540,7 @@ impl WorkspaceOp for PrepareWorktreeFromWorktreeReq {
|
||||
spawn_task: result.spawn_task,
|
||||
response: Some(
|
||||
serde_json::to_value(&resp)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?,
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?,
|
||||
),
|
||||
error: None,
|
||||
}),
|
||||
@@ -572,7 +562,7 @@ impl WorkspaceOp for CreateWorktreeFromWorktreeSyncReq {
|
||||
let req = crate::worktree::CreateWorktreeFromWorktreeRequest::from(self.inner.clone());
|
||||
crate::worktree::create_worktree_from_worktree_sync(&req)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -583,8 +573,8 @@ impl WorkspaceOp for WorktreeDbRebuildReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let report = crate::worktree::worktree_db_rebuild()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::HubError(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]
|
||||
@@ -595,7 +585,7 @@ impl WorkspaceOp for WorktreeDbPathReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let path = crate::worktree::worktree_db_path()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(WorktreeDbPathResponse {
|
||||
path: Some(path.display().to_string()),
|
||||
})
|
||||
@@ -864,8 +854,8 @@ fn hook_registry_to_wire(
|
||||
registry: &kigi_hooks::discovery::HookRegistry,
|
||||
) -> WorkspaceResult<HookRegistryWire> {
|
||||
let value =
|
||||
serde_json::to_value(registry).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
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
|
||||
@@ -873,8 +863,8 @@ fn hook_registry_to_wire(
|
||||
fn wire_to_hook_registry(
|
||||
wire: &HookRegistryWire,
|
||||
) -> WorkspaceResult<kigi_hooks::discovery::HookRegistry> {
|
||||
let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
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 {
|
||||
@@ -886,56 +876,6 @@ impl WorkspaceOp for HookRegistryReq {
|
||||
hook_registry_to_wire(&ws.hook_registry())
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for PutFilesReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
ws.put_files(self.files.clone()).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for GetFilesReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
ws.get_files(self.files.clone()).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsListReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::list(ws, self).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsStatReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::stat(ws, self).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsReadFileReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::read_file(ws, self).await
|
||||
}
|
||||
}
|
||||
/// 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
|
||||
@@ -972,7 +912,7 @@ impl WorkspaceOp for CodeGotoDefinitionReq {
|
||||
let result = handle
|
||||
.goto_definition(std::path::PathBuf::from(&self.file), self.line, self.col)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(query_result_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -992,7 +932,7 @@ impl WorkspaceOp for CodeGotoReferencesReq {
|
||||
self.include_definition,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(query_result_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +950,7 @@ impl WorkspaceOp for CodeFindDefinitionsReq {
|
||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(symbol_locations_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1028,7 +968,7 @@ impl WorkspaceOp for CodeFindReferencesReq {
|
||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(symbol_locations_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1105,9 +1045,9 @@ impl WorkspaceOp for CreateWorktreeRequest {
|
||||
let result = crate::worktree::prepare_worktree_creation(self).await;
|
||||
match result.response {
|
||||
Ok(resp) => {
|
||||
serde_json::to_value(resp).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
serde_json::to_value(resp).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
Err(e) => Err(WorkspaceError::HubError(e.to_string())),
|
||||
Err(e) => Err(WorkspaceError::Internal(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1121,8 +1061,8 @@ impl WorkspaceOp for RemoveWorktreeRequest {
|
||||
let copy_ctx = crate::worktree::BackgroundCopyContext::new();
|
||||
let result = crate::worktree::remove_worktree(self, ©_ctx)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1134,8 +1074,8 @@ impl WorkspaceOp for ApplyWorktreeRequest {
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let result = crate::worktree::apply_worktree(self)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1147,8 +1087,8 @@ impl WorkspaceOp for WorktreeListReq {
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let records =
|
||||
crate::worktree::list_worktrees(self.repo.as_deref(), &self.types, self.include_all)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(records).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(records).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1159,8 +1099,8 @@ impl WorkspaceOp for WorktreeShowReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let record = crate::worktree::show_worktree(&self.id_or_path)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(record).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(record).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1175,9 +1115,9 @@ impl WorkspaceOp for WorktreeGcReq {
|
||||
crate::worktree::gc_worktrees_mgmt(dry_run, max_age_secs, force)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.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]
|
||||
@@ -1188,27 +1128,22 @@ impl WorkspaceOp for WorktreeDbStatsReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let stats = crate::worktree::worktree_db_stats()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(stats).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.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.
|
||||
///
|
||||
/// - **`Local`** — 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.
|
||||
///
|
||||
/// - **`Proxy`** — wraps a [`WorkspaceClient`] connected to a remote hub.
|
||||
/// Everything routes through hub WebSocket to a remote workspace server.
|
||||
/// 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 },
|
||||
/// Proxy mode — routes through hub RPC.
|
||||
Proxy { client: WorkspaceClient },
|
||||
}
|
||||
impl WorkspaceOps {
|
||||
/// Construct a local-mode ops handle.
|
||||
@@ -1219,36 +1154,10 @@ impl WorkspaceOps {
|
||||
pub fn local(handle: WorkspaceHandle) -> Self {
|
||||
Self::Local { handle }
|
||||
}
|
||||
/// Construct a proxy-mode ops handle.
|
||||
pub fn proxy(harness: Arc<ToolHarness>) -> Self {
|
||||
Self::Proxy {
|
||||
client: WorkspaceClient::new((*harness).clone()),
|
||||
}
|
||||
}
|
||||
/// Construct a proxy-mode ops handle sharing a pre-created connected
|
||||
/// flag. The same `Arc<AtomicBool>` should be wired into the harness
|
||||
/// builder's `on_reconnect` callback so reconnects reset the flag.
|
||||
pub fn proxy_with_connected(harness: Arc<ToolHarness>, connected: Arc<AtomicBool>) -> Self {
|
||||
Self::Proxy {
|
||||
client: WorkspaceClient::with_connected_flag((*harness).clone(), connected),
|
||||
}
|
||||
}
|
||||
/// Whether this handle routes through the server (proxy mode).
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
matches!(self, Self::Proxy { .. })
|
||||
}
|
||||
/// Access the underlying workspace RPC client (proxy mode only).
|
||||
pub fn client(&self) -> Option<&WorkspaceClient> {
|
||||
match self {
|
||||
Self::Proxy { client } => Some(client),
|
||||
Self::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
/// Access the underlying workspace handle (local mode only).
|
||||
/// Access the underlying workspace handle.
|
||||
pub fn workspace_handle(&self) -> Option<&WorkspaceHandle> {
|
||||
match self {
|
||||
Self::Local { handle } => Some(handle),
|
||||
Self::Proxy { .. } => None,
|
||||
}
|
||||
}
|
||||
/// Create the workspace session and bind the agent's toolset for local mode.
|
||||
@@ -1274,9 +1183,7 @@ impl WorkspaceOps {
|
||||
toolset: Arc<kigi_tools::registry::types::FinalizedToolset>,
|
||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||
) -> WorkspaceResult<()> {
|
||||
let Self::Local { handle } = self else {
|
||||
return Ok(());
|
||||
};
|
||||
let Self::Local { handle } = self;
|
||||
if handle.session(session_id).is_none() {
|
||||
handle.create_session_with_tracker_and_viewer_ctx(
|
||||
session_id,
|
||||
@@ -1296,9 +1203,7 @@ impl WorkspaceOps {
|
||||
}
|
||||
/// Release the workspace session. No-op in proxy mode.
|
||||
pub fn end_local_session(&self, session_id: &str) {
|
||||
let Self::Local { handle } = self else {
|
||||
return;
|
||||
};
|
||||
let Self::Local { handle } = self;
|
||||
handle.on_session_ended(session_id);
|
||||
if let Err(e) = handle.drop_session(session_id, session_id) {
|
||||
tracing::debug!(
|
||||
@@ -1312,82 +1217,26 @@ impl WorkspaceOps {
|
||||
session_id: &str,
|
||||
payload: &kigi_tool_protocol::turn_hook::BeforeTurnPayload,
|
||||
) {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
handle.on_before_turn(session_id, payload).await;
|
||||
}
|
||||
Self::Proxy { .. } => {
|
||||
tracing::debug!("on_before_turn called on Proxy WorkspaceOps (no-op)");
|
||||
}
|
||||
}
|
||||
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,
|
||||
) {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
handle.on_after_turn(session_id, payload).await;
|
||||
}
|
||||
Self::Proxy { .. } => {
|
||||
tracing::debug!("on_after_turn called on Proxy WorkspaceOps (no-op)");
|
||||
}
|
||||
}
|
||||
let Self::Local { handle } = self;
|
||||
handle.on_after_turn(session_id, payload).await;
|
||||
}
|
||||
pub async fn rpc_raw(&self, method: &str, params: Value) -> WorkspaceResult<Value> {
|
||||
let client = match self {
|
||||
Self::Proxy { client } => client,
|
||||
Self::Local { .. } => {
|
||||
return Err(WorkspaceError::HubError(
|
||||
"rpc not available in local mode".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
client
|
||||
.rpc_raw(method, params)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
}
|
||||
async fn rpc<R: WorkspaceRpc>(&self, req: &R) -> WorkspaceResult<R::Response> {
|
||||
let params = serde_json::to_value(req)
|
||||
.map_err(|e| WorkspaceError::HubError(format!("serialize failed: {e}")))?;
|
||||
let terminal = self.rpc_raw(R::METHOD, params).await?;
|
||||
let envelope: crate::rpc_envelope::RpcEnvelope<R::Response> =
|
||||
serde_json::from_value(terminal)
|
||||
.map_err(|e| WorkspaceError::HubError(format!("envelope parse failed: {e}")))?;
|
||||
envelope
|
||||
.into_result()
|
||||
.map_err(crate::rpc_envelope::rpc_error_to_workspace)
|
||||
}
|
||||
/// Dispatch a typed operation in either local or proxy mode.
|
||||
///
|
||||
/// - **Local mode**: calls `op.execute(handle, session_id)` directly.
|
||||
/// - **Proxy mode**: serializes the op and routes through the server RPC.
|
||||
/// The server handler owns session context, so `session_id` is only
|
||||
/// needed for local `execute()`.
|
||||
/// 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> {
|
||||
let mode = match self {
|
||||
Self::Local { .. } => "local",
|
||||
Self::Proxy { .. } => "proxy",
|
||||
};
|
||||
tracing::debug!(method = Op::METHOD, mode, "WorkspaceOps::dispatch");
|
||||
match self {
|
||||
Self::Local { handle } => op.execute(handle, session_id).await,
|
||||
Self::Proxy { .. } => self.rpc(op).await,
|
||||
}
|
||||
}
|
||||
pub async fn workspace_info(&self) -> WorkspaceResult<Value> {
|
||||
self.rpc(&WorkspaceInfoReq {}).await
|
||||
}
|
||||
/// **DEPRECATED**: Use [`Self::git_status_ext`] with `format: GitStatusFormat::Prompt`
|
||||
/// instead. This method will be removed in a future release.
|
||||
pub async fn git_status(&self) -> WorkspaceResult<Value> {
|
||||
self.rpc(&GitStatusReq {}).await
|
||||
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.
|
||||
///
|
||||
@@ -1408,52 +1257,9 @@ impl WorkspaceOps {
|
||||
let wire = self.dispatch(&HookRegistryReq {}, None).await?;
|
||||
wire_to_hook_registry(&wire)
|
||||
}
|
||||
pub async fn begin_prompt(&self, session_id: &str, prompt_index: usize) -> WorkspaceResult<()> {
|
||||
self.rpc(&BeginPromptReq {
|
||||
session_id: session_id.to_owned(),
|
||||
prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn end_prompt(&self, session_id: &str, prompt_index: usize) -> WorkspaceResult<()> {
|
||||
self.rpc(&EndPromptReq {
|
||||
session_id: session_id.to_owned(),
|
||||
prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn get_rewind_points(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> WorkspaceResult<Vec<crate::session::file_state::RewindPoint>> {
|
||||
self.rpc(&GetRewindPointsReq {
|
||||
session_id: session_id.to_owned(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn rewind_to(
|
||||
&self,
|
||||
session_id: &str,
|
||||
target_prompt_index: usize,
|
||||
) -> WorkspaceResult<crate::session::file_state::FileRewindResponse> {
|
||||
self.rpc(&RewindToReq {
|
||||
session_id: session_id.to_owned(),
|
||||
target_prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn put_files(&self, req: PutFilesReq) -> WorkspaceResult<PutFilesRes> {
|
||||
self.dispatch(&req, None).await
|
||||
}
|
||||
pub async fn get_files(&self, req: GetFilesReq) -> WorkspaceResult<GetFilesRes> {
|
||||
self.dispatch(&req, None).await
|
||||
}
|
||||
/// Dispatch a tool call through the workspace.
|
||||
///
|
||||
/// - **Local**: dispatches through the workspace session's
|
||||
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset)
|
||||
/// (in-process). Requires `session_id` to look up the session.
|
||||
/// - **Proxy**: routes through the server `ToolHarness` (remote).
|
||||
/// 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,
|
||||
@@ -1461,57 +1267,23 @@ impl WorkspaceOps {
|
||||
call_id: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<ToolRunResult, kigi_tool_runtime::ToolError> {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
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
|
||||
}
|
||||
Self::Proxy { client } => {
|
||||
if !client.is_connected() {
|
||||
return Err(kigi_tool_runtime::ToolError::network_error(
|
||||
"The workspace server connection was lost. \
|
||||
Please restart your session to reconnect.",
|
||||
));
|
||||
}
|
||||
let tool_id = kigi_tool_protocol::ToolId::new(name).map_err(|e| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"hub_proxy_error",
|
||||
format!("invalid tool name: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut ctx = kigi_tool_runtime::ToolCallContext::default();
|
||||
ctx.call_id =
|
||||
kigi_tool_protocol::ToolCallId::new(call_id.to_owned()).unwrap_or(ctx.call_id);
|
||||
let mut stream = client.harness().call(tool_id, args, ctx).await;
|
||||
let typed = crate::hub_channel::consume_stream_terminal(&mut stream)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
if is_transport_fatal(e) {
|
||||
client.mark_disconnected();
|
||||
}
|
||||
})?;
|
||||
serde_json::from_value::<ToolRunResult>(typed.value).map_err(|e| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"tool_result_deserialize",
|
||||
format!("tool result deserialization failed: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
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"))]
|
||||
@@ -1569,9 +1341,7 @@ mod tests {
|
||||
#[test]
|
||||
fn git_op_cwd_uses_explicit_git_root_per_window() {
|
||||
let ops = WorkspaceOps::for_test();
|
||||
let WorkspaceOps::Local { handle } = &ops else {
|
||||
unreachable!("for_test builds a local handle");
|
||||
};
|
||||
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");
|
||||
@@ -1594,9 +1364,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn end_local_session_drops_bound_toolset() {
|
||||
let ops = WorkspaceOps::for_test();
|
||||
let WorkspaceOps::Local { handle } = &ops else {
|
||||
unreachable!("for_test builds a local handle");
|
||||
};
|
||||
let WorkspaceOps::Local { handle } = &ops;
|
||||
let sid = "sess-teardown";
|
||||
let toolset =
|
||||
std::sync::Arc::new(kigi_tools::registry::types::FinalizedToolset::empty_for_test());
|
||||
|
||||
Reference in New Issue
Block a user