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:
2026-07-17 22:34:10 -04:00
parent 5919526e91
commit fa75eb139a
90 changed files with 452 additions and 9702 deletions
@@ -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, &copy_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());