//! Workspace-side RPC handler for server-proxied workspace method calls. //! //! [`WorkspaceRpcHandler`] implements [`ToolServerHandler`] and dispatches //! `workspace.*` JSON-RPC methods to [`WorkspaceHandle`]. Registered on //! the `ToolServer` with tool_id `workspace_rpc`. use crate::error::{WorkspaceError, WorkspaceResult}; use crate::handle::WorkspaceHandle; use crate::hub_ids::WORKSPACE_RPC_TOOL_ID; use crate::rpc_envelope::{RpcEnvelope, envelope_err}; use crate::workspace_ops::WorkspaceOp; use async_trait::async_trait; use chrono::{DateTime, Utc}; use prometheus::{HistogramVec, IntCounterVec, register_histogram_vec, register_int_counter_vec}; use serde_json::Value; use xai_computer_hub_sdk::ToolServerHandler; use xai_grok_tools::computer::types::TaskKind; use xai_grok_tools::implementations::grok_build::scheduler::interval::interval_to_human; use xai_grok_tools::implementations::grok_build::scheduler::types::{ SchedulerCommand, SchedulerHandle, }; use xai_grok_tools::registry::types::FinalizedToolset; use xai_grok_tools::types::resources::Terminal; use xai_grok_workspace_types::rpc::workspace::{ BackgroundTaskSnapshotWire, ScheduledTaskSnapshotWire, TasksSnapshotResponse, }; use xai_tool_protocol::{HookEvent, HookFrame, SessionId, ToolId, ToolServerEvictParams}; use xai_tool_runtime::{ ToolCallContext, ToolError, ToolErrorKind, ToolStream, TypedToolOutput, terminal_only, }; use xai_tool_types::ToolDescription; /// Deprecation monitor for the self-attested `caller_session_id` param: /// `kind="param_mismatch"` — the param disagreed with the server-bound envelope /// session (envelope trusted); `kind="envelope_absent"` — no envelope /// session, the param was used as a compat fallback. Enforcement /// (envelope-only identity) waits for this to be flat zero. static WORKSPACE_RPC_CALLER_MISMATCH_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter_vec!( "grok_workspace_rpc_caller_mismatch_total", "Mutation RPCs whose caller_session_id param was not backed by a matching \ server-bound envelope session, by method and kind", &["method", "kind"] ) .unwrap() }); /// Audit trail for the deliberate-mutation RPC surface /// (`update_tool_config` / `drop_session` / `configure_mcp`). static WORKSPACE_RPC_MUTATION_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter_vec!( "grok_workspace_rpc_mutation_total", "Session-mutating workspace RPC calls, by method and outcome", &["method", "outcome"] ) .unwrap() }); /// Every dispatched `workspace.*` RPC, by method and result. Unrecognized /// methods collapse to the `unknown` label to keep cardinality bounded. static WORKSPACE_RPC_REQUESTS_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter_vec!( "grok_workspace_rpc_requests_total", "Workspace RPC dispatches, by method and result", &["method", "result"] ) .unwrap() }); /// Failed `workspace.*` RPC dispatches, by method and /// [`WorkspaceError::metric_kind`]. static WORKSPACE_RPC_ERRORS_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter_vec!( "grok_workspace_rpc_errors_total", "Failed workspace RPC dispatches, by method and error kind", &["method", "error_kind"] ) .unwrap() }); /// Per-method wall-clock duration of a `workspace.*` RPC dispatch. static WORKSPACE_RPC_DURATION_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram_vec!( "grok_workspace_rpc_duration_seconds", "Workspace RPC dispatch duration", &["method"], vec![ 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0 ] ) .unwrap() }); const UNKNOWN_METHOD_LABEL: &str = "unknown"; /// Prefix of the [`WorkspaceError::HubError`] for an unrecognized method. Shared /// by the dispatch default arm and the metric classifier so the "collapse to /// `unknown`" decision cannot drift from the error it keys on. const UNKNOWN_METHOD_ERR_PREFIX: &str = "unknown workspace method:"; /// Zero-init this module's metric families. See [`crate::init_metrics`]. pub(crate) fn init_metrics() { WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "error"]) .inc_by(0); WORKSPACE_RPC_ERRORS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"]) .inc_by(0); let _ = WORKSPACE_RPC_DURATION_SECONDS.with_label_values(&[UNKNOWN_METHOD_LABEL]); } /// Resolve the caller identity for a mutation RPC: the server-bound envelope /// session is authoritative; the deprecated `caller_session_id` param is /// only used when no envelope session exists (old call paths). Both /// divergences are counted on [`WORKSPACE_RPC_CALLER_MISMATCH_TOTAL`]. fn resolve_mutation_caller<'a>( method: &'static str, bound_session: Option<&'a str>, param_caller: Option<&'a str>, ) -> WorkspaceResult<&'a str> { match (bound_session, param_caller) { (Some(envelope), Some(param)) => { if envelope != param { WORKSPACE_RPC_CALLER_MISMATCH_TOTAL .with_label_values(&[method, "param_mismatch"]) .inc(); tracing::warn!( method, envelope_session = %envelope, param_caller = %param, "caller_session_id param disagrees with the server-bound envelope session; \ trusting the envelope" ); } Ok(envelope) } (Some(envelope), None) => Ok(envelope), (None, Some(param)) => { WORKSPACE_RPC_CALLER_MISMATCH_TOTAL .with_label_values(&[method, "envelope_absent"]) .inc(); Ok(param) } (None, None) => Err(WorkspaceError::HubError(format!( "{method}: missing caller identity (no bound session and no caller_session_id)" ))), } } /// Audit-log and count a mutation RPC on [`WORKSPACE_RPC_MUTATION_TOTAL`]. /// Failures log at WARN because that arm carries rejected cross-session /// forgeries (`Unauthorized`), the audit trail's most interesting event. fn record_mutation_rpc( method: &'static str, caller: &str, target: &str, result: &WorkspaceResult, ) { let outcome = match result { Ok(_) => "ok", Err(_) => "error", }; WORKSPACE_RPC_MUTATION_TOTAL .with_label_values(&[method, outcome]) .inc(); match result { Ok(_) => tracing::info!(method, caller, target, "workspace mutation rpc"), Err(e) => { tracing::warn!(method, caller, target, error = %e, "workspace mutation rpc failed"); } } } /// No-op notifier for RPC-driven worktree creation. struct NoOpNotifier; #[async_trait] impl crate::worktree::WorktreeNotificationSender for NoOpNotifier { async fn send_worktree_status(&self, _progress: crate::worktree::WorktreeStatus) {} } /// Env escape hatch for the client-facing `workspace.client_fs_*` ops. /// /// Default **on**; setting `WORKSPACE_CLIENT_FS_QUERIES=0` (or `false`) /// disables the ops with a graceful `HubError` that the remote caller /// maps to a fallback. Read per call — flipping the variable needs no /// process restart and tests can toggle it under a lock. fn client_fs_queries_enabled() -> bool { !matches!( std::env::var("WORKSPACE_CLIENT_FS_QUERIES").as_deref(), Ok("0") | Ok("false") ) } /// Reject `workspace.client_fs_*` dispatch when the escape hatch is off. fn ensure_client_fs_queries_enabled() -> WorkspaceResult<()> { if client_fs_queries_enabled() { Ok(()) } else { Err(WorkspaceError::HubError( "client fs queries disabled on this workspace".into(), )) } } /// Generic dispatch helper: deserialize params, execute, serialize result. async fn dispatch_op( params: Value, ws: &WorkspaceHandle, session_id: Option<&str>, ) -> WorkspaceResult { let req: Op = serde_json::from_value(params) .map_err(|e| WorkspaceError::HubError(format!("invalid params for {}: {e}", Op::METHOD)))?; let result = req.execute(ws, session_id).await?; serde_json::to_value(result) .map_err(|e| WorkspaceError::HubError(format!("{}: {e}", Op::METHOD))) } /// List a session's outstanding (not-completed) background terminal tasks from /// the session toolset's `TerminalBackend` resource, mapped to the slim wire /// DTO. Empty when the session has no terminal backend. Source of truth for the /// `workspace.list_background_tasks` RPC (post-compaction system-reminder state). async fn list_outstanding_background_tasks( toolset: &xai_grok_tools::registry::types::FinalizedToolset, ) -> Vec { use xai_grok_tools::computer::types::TaskKind; use xai_grok_tools::types::resources::Terminal; use xai_grok_tools::types::tool::ToolKind; use xai_grok_workspace_types::rpc::workspace::BackgroundTaskSummaryWire; let terminal = { let res = toolset.resources.lock().await; res.get::().map(|t| t.0.clone()) }; let Some(terminal) = terminal else { return Vec::new(); }; let execute_name = toolset.tool_name_for_kind(ToolKind::Execute); let monitor_name = toolset.tool_name_for_kind(ToolKind::Monitor); terminal .list_tasks() .await .into_iter() .filter(|t| !t.completed) .map(|t| { let command = t .display_command .clone() .filter(|s| !s.is_empty()) .unwrap_or(t.command); let tool_name = match t.kind { TaskKind::Monitor => monitor_name.clone(), TaskKind::Bash => execute_name.clone(), }; BackgroundTaskSummaryWire { task_id: t.task_id, command, tool_name, } }) .collect() } /// Incomplete backgrounded terminal tasks + live scheduled tasks (client tray rebuild). async fn tasks_snapshot(toolset: &FinalizedToolset) -> TasksSnapshotResponse { let (terminal, scheduler) = { let res = toolset.resources.lock().await; ( res.get::().map(|t| t.0.clone()), res.get::().cloned(), ) }; let background_tasks = match terminal { Some(terminal) => terminal .list_tasks() .await .into_iter() .filter(|t| t.is_outstanding_background()) .map(|t| { let command = t .display_command .clone() .filter(|s| !s.is_empty()) .unwrap_or(t.command); BackgroundTaskSnapshotWire { task_id: t.task_id, command, kind: match t.kind { TaskKind::Bash => "bash".to_owned(), TaskKind::Monitor => "monitor".to_owned(), }, started_at: DateTime::::from(t.start_time).to_rfc3339(), description: t.description, } }) .collect(), None => Vec::new(), }; let scheduled_tasks = match scheduler { Some(handle) => { let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); let _ = handle.0.send(SchedulerCommand::List { reply: reply_tx }); reply_rx .await .map(|snapshot| snapshot.tasks) .unwrap_or_default() .into_iter() .map(|t| ScheduledTaskSnapshotWire { task_id: t.id.clone(), prompt: t.prompt.clone(), human_schedule: interval_to_human(t.interval_secs), next_fire_at: t.next_fire_at().to_rfc3339(), recurring: t.recurring, created_at: t.created_at.to_rfc3339(), }) .collect() } None => Vec::new(), }; TasksSnapshotResponse { background_tasks, scheduled_tasks, } } /// List the session's TODO items (via `todo_write`) from the session toolset's /// `State` resource, mapped to the slim wire DTO. Empty when the /// session has no todo state. Source of truth for the `workspace.list_todos` /// RPC (post-compaction system-reminder state). async fn list_session_todos( toolset: &xai_grok_tools::registry::types::FinalizedToolset, ) -> Vec { use xai_grok_tools::implementations::grok_build::todo::{TodoState, TodoStatus}; use xai_grok_tools::types::resources::State; use xai_grok_workspace_types::rpc::workspace::TodoSummaryWire; let res = toolset.resources.lock().await; let Some(state) = res.get::>() else { return Vec::new(); }; state .0 .todo_items_with_ids() .map(|(id, item)| { let status = match item.status { TodoStatus::Pending => "pending", TodoStatus::InProgress => "in_progress", TodoStatus::Completed => "completed", TodoStatus::Cancelled => "cancelled", }; TodoSummaryWire { id: id.to_string(), content: item.content.clone(), status: status.to_string(), } }) .collect() } /// Routes JSON-RPC `workspace.*` method calls to [`WorkspaceHandle`]. pub(crate) struct WorkspaceRpcHandler { workspace: WorkspaceHandle, } impl WorkspaceRpcHandler { pub(crate) fn new(workspace: WorkspaceHandle) -> Self { Self { workspace } } /// Route a `workspace.*` method; `bound_session` is the caller's server-bound session. async fn dispatch( &self, method: &str, params: Value, bound_session: Option<&str>, ) -> WorkspaceResult { use crate::file_system::ContentSearchRequest; use crate::file_system::{ FsDeleteFileReq, FsExistsReq, FsListReq, FsReadFileReq, FsWriteFileReq, }; use crate::session::checkpoint::TurnBoundary; use crate::workspace_ops::*; use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest}; use xai_grok_workspace_types::rpc::git::{GitBranchInfoReq, GitMetadataReq}; use xai_grok_workspace_types::rpc::search::FuzzyStatusReq; use xai_grok_workspace_types::rpc::skills::DiscoverPluginsReq; use xai_grok_workspace_types::rpc::workspace::{ ConfigureMcpReq, DropSessionReq, InstallPluginReq, ListBackgroundTasksReq, ListBackgroundTasksResponse, ListTodosReq, ListTodosResponse, LoadEnvrcReq, LoadPermissionsReq, LoadProjectConfigReq, RefreshPluginsReq, ResolveFileReferencesReq, TasksSnapshotReq, ToolDefinitionsReq, UpdateToolConfigReq, }; use xai_grok_workspace_types::rpc::worktree::WorktreeCreateSyncReq; tracing::debug!(method, "workspace rpc dispatch"); let params = if params.is_null() { serde_json::json!({}) } else { params }; match method { ::METHOD => { let cwd = self.workspace.root_cwd()?; let cwd_str = cwd.to_string_lossy().to_string(); let os = std::env::consts::OS; let shell = std::env::var("SHELL") .ok() .and_then(|s| { std::path::Path::new(&s) .file_name() .map(|n| n.to_string_lossy().to_string()) }) .unwrap_or_else(|| "sh".to_string()); Ok(serde_json::json!({ "os": os, "shell": shell, "cwd": cwd_str, })) } ::METHOD => { static DEPRECATION_WARNING: std::sync::Once = std::sync::Once::new(); DEPRECATION_WARNING.call_once(|| { tracing::warn!( "workspace.git_status is deprecated and will be removed in a future \ release. Use workspace.git_status_ext with format: \"prompt\" instead." ); }); let cwd = self.workspace.root_cwd()?; let result = crate::file_system::git_status(cwd) .await .map_err(|e| WorkspaceError::HubError(e.to_string()))?; Ok(Value::String(result)) } ::METHOD => { let cwd = self.workspace.root_cwd()?; match crate::session::git::git_info(&cwd).await { Ok(info) => serde_json::to_value(info) .map_err(|e| WorkspaceError::HubError(e.to_string())), Err(_) => Ok(Value::Null), } } ::METHOD => { let session_id = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))?; let session = self .workspace .session(session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(session_id.into()))?; let defs = session.toolset().tool_definitions(); serde_json::to_value(defs).map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let session_id = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))?; let session = self .workspace .session(session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(session_id.into()))?; let toolset = session.toolset(); let tasks = list_outstanding_background_tasks(toolset.as_ref()).await; serde_json::to_value(ListBackgroundTasksResponse { tasks }) .map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let session_id = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))?; let session = self .workspace .session(session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(session_id.into()))?; let toolset = session.toolset(); let snapshot = tasks_snapshot(toolset.as_ref()).await; serde_json::to_value(snapshot).map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let session_id = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))?; let session = self .workspace .session(session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(session_id.into()))?; let toolset = session.toolset(); let todos = list_session_todos(toolset.as_ref()).await; serde_json::to_value(ListTodosResponse { todos }) .map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let caller = resolve_mutation_caller( "update_tool_config", bound_session, params .get("caller_session_id") .and_then(Value::as_str) .filter(|s| !s.is_empty()), )?; let session_id = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))? .to_owned(); let new_config = serde_json::from_value( params .get("new_config") .cloned() .ok_or_else(|| WorkspaceError::HubError("missing new_config".into()))?, ) .map_err(|e| WorkspaceError::HubError(format!("invalid new_config: {e}")))?; let result = self .workspace .update_tool_config(caller, &session_id, new_config) .await; record_mutation_rpc("update_tool_config", caller, &session_id, &result); result.map(|()| Value::Null) } ::METHOD => { let caller = resolve_mutation_caller( "drop_session", bound_session, params .get("caller_session_id") .and_then(Value::as_str) .filter(|s| !s.is_empty()), )?; let target = params .get("session_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing session_id".into()))?; let result = self.workspace.drop_session(caller, target); record_mutation_rpc("drop_session", caller, target, &result); result.map(|()| Value::Null) } ::METHOD => { let refs: Vec = params .get("refs") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); let cwd = self.workspace.root_cwd()?; let mut results = Vec::new(); for ref_path in &refs { let requested_path = if std::path::Path::new(ref_path).is_absolute() { std::path::PathBuf::from(ref_path) } else { cwd.join(ref_path) }; let full_path = match self .workspace .confine_to_workspace_root(&requested_path) .await { Ok((confined, _)) => confined, Err(e) => { results.push(serde_json::json!({ "path": requested_path.to_string_lossy(), "ref": ref_path, "exists": false, "content": Value::Null, "error": e.to_string(), })); continue; } }; let exists = full_path.exists(); let content = if exists { tokio::fs::read_to_string(&full_path).await.ok() } else { None }; results.push(serde_json::json!({ "path": full_path.to_string_lossy(), "ref": ref_path, "exists": exists, "content": content, })); } Ok(Value::Array(results)) } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { ensure_client_fs_queries_enabled()?; dispatch_op::(params, &self.workspace, None).await } ::METHOD => { ensure_client_fs_queries_enabled()?; dispatch_op::(params, &self.workspace, None).await } ::METHOD => { ensure_client_fs_queries_enabled()?; dispatch_op::(params, &self.workspace, None).await } ::METHOD => { let cwd = self.workspace.root_cwd()?; let skills = crate::discovery::discover_skills(&cwd, self.workspace.shared.skills_config()) .await; Ok(Value::Array(skills)) } ::METHOD => { let cwd = self.workspace.root_cwd()?; let files = crate::discovery::discover_agents_md(&cwd).await; Ok(Value::Array(files)) } ::METHOD => { let cwd = self.workspace.root_cwd()?; let plugins = crate::discovery::discover_plugins( &cwd, self.workspace.shared.plugin_discovery_config(), &crate::discovery::PluginTrustStore::load(), true, ); Ok(Value::Array(plugins)) } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { let cwd = self.workspace.root_cwd()?; Ok(crate::discovery::load_project_config(&cwd)) } ::METHOD => { let cwd = self.workspace.root_cwd()?; Ok(crate::discovery::load_permissions(&cwd, true).await) } ::METHOD => { let cwd = self.workspace.root_cwd()?; let env = crate::envrc::load_envrc_or_empty(&cwd); serde_json::to_value(env).map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let _ = params; Ok(Value::Null) } ::METHOD => { let cwd = self.workspace.root_cwd()?; let plugins = crate::discovery::discover_plugins( &cwd, self.workspace.shared.plugin_discovery_config(), &crate::discovery::PluginTrustStore::load(), true, ); Ok(Value::Array(plugins)) } ::METHOD => { let session_id = bound_session.ok_or_else(|| { WorkspaceError::HubError("configure_mcp requires a bound session".into()) })?; let configs: Vec = serde_json::from_value( params .get("mcp_servers") .cloned() .unwrap_or(Value::Array(vec![])), ) .map_err(|e| WorkspaceError::HubError(format!("invalid mcp_servers: {e}")))?; let result = async { if self.workspace.session(session_id).is_none() { tracing::info!( session_id, "workspace.configure_mcp: session not found, creating on demand" ); match self .workspace .create_session_with_config( session_id, None, None, crate::capability::CapabilityMode::All, None, true, ) { Ok(session) => { self.workspace.finalize_session_setup(&session).await; } Err(WorkspaceError::SessionAlreadyExists(_)) => { tracing::debug!( session_id, "workspace.configure_mcp: session created concurrently, using existing" ); } Err(e) => return Err(e), } } self.workspace.start_session_mcp_servers(session_id, configs).await } .await; record_mutation_rpc("configure_mcp", "self", session_id, &result); serde_json::to_value(&result?) .map_err(|e| WorkspaceError::HubError(format!("serialize McpStartResult: {e}"))) } ::METHOD => { let cwd = self.workspace.root_cwd()?; let metadata = crate::session::git::resolve_persisted_session_git_metadata_sync(&cwd); Ok(serde_json::to_value(metadata).unwrap_or(Value::Null)) } ::METHOD => { let search_id = params .get("search_id") .and_then(Value::as_str) .ok_or_else(|| WorkspaceError::HubError("missing search_id".into()))?; let results = self.workspace.fuzzy_get_results(search_id).await; match results { Some(data) => serde_json::to_value(data) .map_err(|e| WorkspaceError::HubError(e.to_string())), None => Ok(Value::Null), } } "workspace.worktree_create_from_worktree" | ::METHOD => { dispatch_op::(params, &self.workspace, None) .await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { let req: crate::worktree::CreateWorktreeRequest = serde_json::from_value(params) .map_err(|e| { WorkspaceError::HubError(format!("invalid create_sync params: {e}")) })?; let result = crate::worktree::create_worktree_streaming(&req, &NoOpNotifier).await; serde_json::to_value(result).map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session) .await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session) .await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, bound_session).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { dispatch_op::(params, &self.workspace, None).await } ::METHOD => { let req: BeginPromptReq = serde_json::from_value(params).map_err(|e| { WorkspaceError::HubError(format!("invalid params for begin_prompt: {e}")) })?; self.workspace .session(&req.session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(req.session_id.clone()))?; self.workspace .on_turn_boundary( &req.session_id, TurnBoundary::rewind_begin(req.prompt_index), ) .await; Ok(Value::Null) } ::METHOD => { let req: EndPromptReq = serde_json::from_value(params).map_err(|e| { WorkspaceError::HubError(format!("invalid params for end_prompt: {e}")) })?; self.workspace .session(&req.session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(req.session_id.clone()))?; self.workspace .on_turn_boundary( &req.session_id, TurnBoundary::rewind_finalize(req.prompt_index), ) .await; Ok(Value::Null) } ::METHOD => { let req: GetRewindPointsReq = serde_json::from_value(params).map_err(|e| { WorkspaceError::HubError(format!("invalid params for get_rewind_points: {e}")) })?; let session = self .workspace .session(&req.session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(req.session_id.clone()))?; let points = session .file_state_tracker() .get_rewind_points_normalized(session.cwd()) .await; serde_json::to_value(points).map_err(|e| WorkspaceError::HubError(e.to_string())) } ::METHOD => { let req: RewindToReq = serde_json::from_value(params).map_err(|e| { WorkspaceError::HubError(format!("invalid params for rewind_to: {e}")) })?; self.workspace .session(&req.session_id) .ok_or_else(|| WorkspaceError::SessionNotFound(req.session_id.clone()))?; let response = self .workspace .rewind_to(&req.session_id, req.target_prompt_index) .await; serde_json::to_value(response).map_err(|e| WorkspaceError::HubError(e.to_string())) } _ => { tracing::warn!(method, "unknown workspace rpc method"); Err(WorkspaceError::HubError(format!( "{UNKNOWN_METHOD_ERR_PREFIX} {method}" ))) } } } } #[async_trait] impl ToolServerHandler for WorkspaceRpcHandler { fn tool_id(&self) -> ToolId { ToolId::new(WORKSPACE_RPC_TOOL_ID).expect("constant is a valid ToolId") } fn description(&self) -> ToolDescription { ToolDescription::new( WORKSPACE_RPC_TOOL_ID, "Routes workspace RPC calls to the local workspace handle.", ) } fn input_schema(&self) -> Option { Some(serde_json::json!({ "type": "object", "properties": { "method": { "type": "string", "description": "The workspace.* method to invoke" }, "params": { "type": "object", "description": "Method parameters" } }, "required": ["method"] })) } async fn handle_call(&self, ctx: ToolCallContext, args: Value) -> ToolStream { let tool_id = self.tool_id(); let method = match args.get("method").and_then(Value::as_str) { Some(m) => m, None => { return terminal_only(Err(ToolError::new( ToolErrorKind::InvalidArguments, "missing required field: method", ))); } }; tracing::debug!("workspace rpc call from server"); let params = args .get("params") .cloned() .unwrap_or(Value::Object(Default::default())); let bound_session = ctx.extensions.get::(); let start = std::time::Instant::now(); let result = self .dispatch( method, params, bound_session.as_deref().map(|s| s.0.as_str()), ) .await; let is_unknown_method = matches!( &result, Err(WorkspaceError::HubError(msg)) if msg.starts_with(UNKNOWN_METHOD_ERR_PREFIX) ); let method_label = if is_unknown_method { UNKNOWN_METHOD_LABEL } else { method }; WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&[method_label, if result.is_ok() { "ok" } else { "error" }]) .inc(); if let Err(e) = &result { WORKSPACE_RPC_ERRORS_TOTAL .with_label_values(&[method_label, e.metric_kind()]) .inc(); } WORKSPACE_RPC_DURATION_SECONDS .with_label_values(&[method_label]) .observe(start.elapsed().as_secs_f64()); let envelope = match result { Ok(value) => RpcEnvelope::ok(value), Err(ref e) => envelope_err(e), }; let envelope = serde_json::to_value(envelope).expect("RpcEnvelope serialization is infallible"); terminal_only(Ok(TypedToolOutput::from_value(tool_id, envelope))) } async fn handle_hook(&self, session_id: SessionId, frame: HookFrame) { match frame.event { HookEvent::Cancel => { if let Some(call_id) = &frame.call_id { tracing::info!(%session_id, %call_id, "cancel hook received"); self.workspace .cancel_tool_call(session_id.as_str(), call_id.as_str()); } else { tracing::info!(%session_id, "cancel hook received (session-wide)"); self.workspace.cancel_all_tool_calls(session_id.as_str()); } } HookEvent::SessionEnded => { tracing::info!(%session_id, "session_ended hook received"); self.workspace .teardown_session_mcp(session_id.as_str()) .await; self.workspace.on_session_ended(session_id.as_str()); } HookEvent::Custom { kind, payload } => { use xai_tool_protocol::turn_hook::{ AFTER_TURN_KIND, AfterTurnPayload, BEFORE_TURN_KIND, BeforeTurnPayload, }; match kind.as_str() { BEFORE_TURN_KIND => { match serde_json::from_value::(payload) { Ok(p) => { tracing::info!( session = %session_id, turn = p.turn_number, model = %p.model_id, "before_turn hook received" ); self.workspace.on_before_turn(session_id.as_str(), &p).await; } Err(e) => { tracing::warn!( error = %e, "before_turn payload deserialization failed" ); } } } AFTER_TURN_KIND => match serde_json::from_value::(payload) { Ok(p) => { tracing::info!( session = %session_id, turn = p.turn_number, outcome = ?p.outcome, duration_ms = p.duration_ms, "after_turn hook received" ); self.workspace.on_after_turn(session_id.as_str(), &p).await; } Err(e) => { tracing::warn!( error = %e, "after_turn payload deserialization failed" ); } }, _ => { tracing::debug!( kind = %kind, session = %session_id, "unrecognized custom hook kind" ); } } } HookEvent::Pause | HookEvent::Resume => { tracing::debug!(%session_id, event = ?frame.event, "hook not yet implemented"); } } } async fn handle_hook_request(&self, session_id: SessionId, frame: HookFrame) -> Option { use xai_tool_protocol::turn_hook::{self, TurnHookRequest}; let HookEvent::Custom { kind, payload } = frame.event else { return None; }; if kind != turn_hook::TURN_HOOK_KIND { return None; } let no_op = || serde_json::to_value(turn_hook::HookReply::default()).ok(); if self.workspace.shared.activity_tracker.is_draining() || self.workspace.session(session_id.as_str()).is_none() { return no_op(); } let request: TurnHookRequest = match serde_json::from_value(payload) { Ok(r) => r, Err(e) => { tracing::warn!(error = %e, %session_id, "invalid turn hook request"); return no_op(); } }; let reply = self .workspace .compute_turn_injections(session_id.as_str(), &request) .await; Some(serde_json::to_value(&reply).unwrap_or(Value::Null)) } /// Hub-issued `tool_server.evict`. Always tears the evicted session down /// (MCP bridges + activity/writer state, like the `SessionEnded` hook), then /// runs the global two-phase drain **only** when no other session survives — /// a global drain shuts down the *shared* upload queue, which must not happen /// while another session is live. Idempotent across fan-out and safe for an /// already-gone session id. /// /// Contract: the server-supplied `grace_period_ms` budgets the drain and is /// therefore honored only when evicting the **last** live session. For a /// multi-session workspace the evicted session is dropped immediately /// (no per-session drain) because the shared upload queue cannot be flushed /// or closed without affecting the survivors. async fn handle_evict(&self, params: ToolServerEvictParams) { let sid = params.session_id.as_str(); self.workspace.teardown_session_mcp(sid).await; self.workspace.on_session_ended(sid); let (became_empty, start_drain) = { let mut sessions = self.workspace.shared.sessions.write(); if let Some(session) = sessions.remove(sid) { session.abort_system_notify_forwarder(); session.shutdown_terminal_backend(); session.cancel_hunk_tracker(); } let empty = sessions.is_empty(); let already_winding_down = self.workspace.activity_tracker().is_draining(); let start = empty && !already_winding_down; if start { self.workspace.activity_tracker().set_draining(); } (empty, start) }; if !start_drain { if became_empty { tracing::info!( session = %params.session_id, reason = %params.reason, "workspace: hub evict — already draining/shutting down; dropped session only" ); } else { tracing::info!( session = %params.session_id, reason = %params.reason, "workspace: hub evict — other sessions live; dropped session only" ); } return; } let grace = std::time::Duration::from_millis(params.grace_period_ms); tracing::info!( session = %params.session_id, reason = %params.reason, grace_period_ms = params.grace_period_ms, "workspace: hub evict — last session; commencing two-phase drain" ); let unfinished = self .workspace .two_phase_drain(grace, crate::handle::DrainReason::Evict) .await; if unfinished > 0 { tracing::warn!( session = %params.session_id, unfinished, "workspace: hub evict drain left items pending" ); } self.workspace.activity_tracker().set_shutting_down(); } } #[cfg(test)] mod tests { use super::*; use crate::capability::CapabilityMode; use crate::handle::tests::{ background_capable_cfg, make_confining_handle, make_handle, start_background_sleep, }; use xai_grok_tools::implementations::grok_build::scheduler::types::{ ScheduledTask, SchedulerState, }; use xai_grok_tools::types::resources::State; use xai_tool_protocol::turn_hook; /// Helper: consume the first item from a ToolStream. async fn next_item( stream: &mut ToolStream, ) -> Option> { use std::task::Context; std::future::poll_fn(|cx: &mut Context<'_>| stream.as_mut().poll_next(cx)).await } fn turn_hook_frame(session: &str, req: &turn_hook::TurnHookRequest) -> HookFrame { HookFrame::custom_request( SessionId::new(session).unwrap(), "hk-test".to_owned(), turn_hook::TURN_HOOK_KIND.to_owned(), serde_json::to_value(req).unwrap(), ) } #[tokio::test] async fn handle_hook_request_turn_hook_returns_reply() { let handler = WorkspaceRpcHandler::new(make_handle()); let req = turn_hook::TurnHookRequest::Before(turn_hook::BeforeTurnPayload { turn_number: 1, model_id: "grok-3".to_owned(), yolo_mode: false, conversation_message_count: 0, session_relationship: "primary".to_owned(), schema_version: "1.0".to_owned(), }); let value = handler .handle_hook_request( SessionId::new("main").unwrap(), turn_hook_frame("main", &req), ) .await .expect("turn hook claimed"); let reply: turn_hook::HookReply = serde_json::from_value(value).unwrap(); assert_eq!(reply, turn_hook::HookReply::default()); } #[tokio::test] async fn handle_hook_request_ignores_non_turn_hook_kind() { let handler = WorkspaceRpcHandler::new(make_handle()); let frame = HookFrame::custom_request( SessionId::new("main").unwrap(), "hk-x".to_owned(), "some_other_kind".to_owned(), serde_json::json!({}), ); assert!( handler .handle_hook_request(SessionId::new("main").unwrap(), frame) .await .is_none() ); } #[tokio::test] async fn handle_hook_request_unbound_session_is_noop() { let handler = WorkspaceRpcHandler::new(make_handle()); let req = turn_hook::TurnHookRequest::Before(turn_hook::BeforeTurnPayload { turn_number: 1, model_id: "grok-3".to_owned(), yolo_mode: false, conversation_message_count: 0, session_relationship: "primary".to_owned(), schema_version: "1.0".to_owned(), }); let value = handler .handle_hook_request( SessionId::new("never-bound").unwrap(), turn_hook_frame("never-bound", &req), ) .await .expect("fail-open no-op reply"); let reply: turn_hook::HookReply = serde_json::from_value(value).unwrap(); assert_eq!(reply, turn_hook::HookReply::default()); } #[tokio::test] async fn dispatch_unknown_method_returns_hub_error() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.nonexistent", Value::Null, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(msg)) if msg.contains("unknown workspace method")) ); } /// A hub evict runs the two-phase drain then settles into terminal /// ShuttingDown (not a lingering Draining) for an evicted workspace. #[tokio::test] async fn handle_evict_triggers_two_phase_drain() { use xai_tool_protocol::ToolServerLifecycleStatus; let handle = make_handle(); let tracker = handle.activity_tracker().clone(); let handler = WorkspaceRpcHandler::new(handle); assert!(!tracker.is_draining(), "not draining before evict"); handler .handle_evict(ToolServerEvictParams { session_id: SessionId::new("main").expect("valid session id"), reason: "preemption".into(), grace_period_ms: 200, }) .await; let snap = tracker.snapshot(); assert_eq!( snap.status, ToolServerLifecycleStatus::ShuttingDown, "an evicted workspace must end in terminal ShuttingDown, not lingering Draining" ); assert!( snap.drain_started_ms.is_some(), "evict drain must stamp drain_started_ms" ); } /// A hub evict shuts the evicted session's terminal backend down /// explicitly: the actor stops even while other `Arc`s to the backend are /// still alive (mirrors `drop_session_shuts_down_terminal_backend_explicitly`). #[tokio::test] async fn handle_evict_shuts_down_terminal_backend_explicitly() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let retained_backend = session.terminal_backend().clone(); let retained_toolset = session.toolset(); drop(session); let handler = WorkspaceRpcHandler::new(handle); handler .handle_evict(ToolServerEvictParams { session_id: SessionId::new("main").expect("valid session id"), reason: "preemption".into(), grace_period_ms: 100, }) .await; crate::handle::tests::assert_backend_stops(&retained_backend).await; drop(retained_toolset); } /// Isolation matrix #1/#3 at the RPC surface: `workspace.list_background_tasks` /// (the post-compaction reminder source of truth) stays truthful across /// both rebind shapes. The task stays listed through a `Reused` rebind /// AND a `Reresolved` toolset swap — reading it through each rebind's /// CURRENT toolset — and leaves the list only when explicitly killed. #[tokio::test] async fn list_background_tasks_rpc_stays_truthful_across_rebinds() { use crate::capability::CapabilityMode; use crate::handle::RebindOutcome; use crate::handle::tests::{background_capable_cfg, start_background_sleep}; use crate::session::tool_config::test_support::tc; use xai_grok_tools::registry::types::ToolServerConfig; use xai_grok_workspace_types::rpc::workspace::ListBackgroundTasksResponse; let handle = make_handle(); let cfg = background_capable_cfg(); let session = handle .create_session_with_config( "bg-rpc", None, Some(cfg.clone()), CapabilityMode::All, None, false, ) .expect("create background-capable session"); session.set_bind_tool_config_fingerprint(serde_json::to_value(&cfg).ok()); let out_dir = tempfile::tempdir().expect("temp dir"); let bg = start_background_sleep(&session, out_dir.path(), "bg-rpc-task").await; let handler = WorkspaceRpcHandler::new(handle.clone()); async fn list_tasks( handler: &WorkspaceRpcHandler, ) -> Vec { let value = handler .dispatch( "workspace.list_background_tasks", serde_json::json!({"session_id": "bg-rpc"}), Some("bg-rpc"), ) .await .expect("list_background_tasks rpc"); serde_json::from_value::(value) .expect("decode response") .tasks } let tasks = list_tasks(&handler).await; assert_eq!(tasks.len(), 1, "the running task must be listed"); assert_eq!(tasks[0].task_id, bg.task_id); assert_eq!( tasks[0].tool_name.as_deref(), Some("run_terminal_cmd"), "the creator tool is named from the live toolset" ); let (_, outcome) = handle .rebind_existing_hub_session( "bg-rpc", Some(cfg.clone()), serde_json::to_value(&cfg).ok(), ) .await .expect("session exists"); assert_eq!(outcome, RebindOutcome::Reused); let tasks = list_tasks(&handler).await; assert_eq!(tasks.len(), 1, "the task must survive a reused rebind"); let read_only = ToolServerConfig { tools: vec![tc( "GrokBuild:read_file", Some(xai_grok_tools::types::tool::ToolKind::Read), )], behavior_preset: None, }; let (_, outcome) = handle .rebind_existing_hub_session( "bg-rpc", Some(read_only.clone()), serde_json::to_value(&read_only).ok(), ) .await .expect("session exists"); assert_eq!(outcome, RebindOutcome::Reresolved); let tasks = list_tasks(&handler).await; assert_eq!(tasks.len(), 1, "the task must survive the toolset swap"); assert_eq!(tasks[0].task_id, bg.task_id); assert_eq!( tasks[0].tool_name, None, "the swapped-in toolset has no execute tool to name" ); session.terminal_backend().kill_task(&bg.task_id).await; let tasks = list_tasks(&handler).await; assert!( tasks.is_empty(), "a killed task must leave the outstanding list: {tasks:?}" ); } /// `workspace.tasks_snapshot` (GC-614 part 3): returns the outstanding /// background task with kind/started_at, plus scheduled tasks (empty when /// no scheduler resource exists), and drops the task once killed. #[tokio::test] async fn tasks_snapshot_rpc_lists_outstanding_background_tasks() { let handle = make_handle(); let cfg = background_capable_cfg(); let session = handle .create_session_with_config( "snap-rpc", None, Some(cfg.clone()), CapabilityMode::All, None, false, ) .expect("create background-capable session"); session.set_bind_tool_config_fingerprint(serde_json::to_value(&cfg).ok()); let out_dir = tempfile::tempdir().expect("temp dir"); let bg = start_background_sleep(&session, out_dir.path(), "snap-rpc-task").await; let handler = WorkspaceRpcHandler::new(handle.clone()); async fn snapshot(handler: &WorkspaceRpcHandler) -> TasksSnapshotResponse { let value = handler .dispatch( "workspace.tasks_snapshot", serde_json::json!({"session_id": "snap-rpc"}), Some("snap-rpc"), ) .await .expect("tasks_snapshot rpc"); serde_json::from_value(value).expect("decode response") } let snap = snapshot(&handler).await; assert_eq!( snap.background_tasks.len(), 1, "the running task must be listed" ); let task = &snap.background_tasks[0]; assert_eq!(task.task_id, bg.task_id); assert_eq!(task.kind, "bash"); assert!( task.description.is_none(), "start_background_sleep does not set description: {:?}", task.description ); assert!( DateTime::parse_from_rfc3339(&task.started_at).is_ok(), "started_at must be RFC3339: {}", task.started_at ); assert!( snap.scheduled_tasks.is_empty(), "no scheduler resource in this toolset: {:?}", snap.scheduled_tasks ); { use crate::handle::tests::terminal_run_request; let mut req = terminal_run_request("sleep 30", out_dir.path(), "snap-desc-task"); req.description = Some("build frontend".into()); let desc_bg = session .terminal_backend() .run_background(req) .await .expect("start described background task"); let snap = snapshot(&handler).await; let described = snap .background_tasks .iter() .find(|t| t.task_id == desc_bg.task_id) .expect("described task in snapshot"); assert_eq!(described.description.as_deref(), Some("build frontend")); session.terminal_backend().kill_task(&desc_bg.task_id).await; } session.terminal_backend().kill_task(&bg.task_id).await; let snap = snapshot(&handler).await; assert!( snap.background_tasks.is_empty(), "a killed task must leave the snapshot: {:?}", snap.background_tasks ); { let toolset = session.toolset(); let mut resources = toolset.resources.lock().await; let state = resources.get_or_default::>(); let mut task = ScheduledTask::new(300, "check CI".into(), true, false); task.id = "loop-1".into(); state.tasks.push(task); } let snap = snapshot(&handler).await; assert_eq!(snap.scheduled_tasks.len(), 1); let loop_task = &snap.scheduled_tasks[0]; assert_eq!(loop_task.task_id, "loop-1"); assert_eq!(loop_task.prompt, "check CI"); assert_eq!(loop_task.human_schedule, "every 5 minutes"); assert!(loop_task.recurring); assert!( DateTime::parse_from_rfc3339(&loop_task.next_fire_at).is_ok(), "next_fire_at must be RFC3339: {}", loop_task.next_fire_at ); } /// FG in-flight out of snapshot; after backgrounding in; completed BG out. /// Preconditions ensure a bare `!completed` filter would fail. #[tokio::test] async fn tasks_snapshot_excludes_foreground_and_completed_processes() { use crate::handle::tests::terminal_run_request; use std::time::{Duration, Instant}; let handle = make_handle(); let cfg = background_capable_cfg(); let session = handle .create_session_with_config( "snap-fg-rpc", None, Some(cfg.clone()), CapabilityMode::All, None, false, ) .expect("create background-capable session"); session.set_bind_tool_config_fingerprint(serde_json::to_value(&cfg).ok()); let out_dir = tempfile::tempdir().expect("temp dir"); let handler = WorkspaceRpcHandler::new(handle.clone()); async fn snapshot(handler: &WorkspaceRpcHandler) -> TasksSnapshotResponse { let value = handler .dispatch( "workspace.tasks_snapshot", serde_json::json!({"session_id": "snap-fg-rpc"}), Some("snap-fg-rpc"), ) .await .expect("tasks_snapshot rpc"); serde_json::from_value(value).expect("decode response") } let backend = session.terminal_backend().clone(); let fg_req = terminal_run_request("sleep 30", out_dir.path(), "snap-fg-task"); let fg_join = tokio::spawn(async move { backend.run(fg_req).await }); let poll_deadline = Instant::now() + Duration::from_secs(5); loop { let listed = session.terminal_backend().list_tasks().await; if listed.iter().any(|t| !t.completed && !t.is_backgrounded) { break; } assert!( Instant::now() < poll_deadline, "timeout waiting for incomplete FG in list_tasks: {listed:?}" ); tokio::time::sleep(Duration::from_millis(20)).await; } let snap = snapshot(&handler).await; assert!( snap.background_tasks.is_empty(), "in-flight FG must not appear in tasks_snapshot: {:?}", snap.background_tasks ); assert!( session .terminal_backend() .background_foreground_command("snap-fg-task") .await, "expected FG process snap-fg-task to background" ); let snap = snapshot(&handler).await; assert!( snap.background_tasks .iter() .any(|t| t.task_id == "snap-fg-task"), "backgrounded former FG must appear: {:?}", snap.background_tasks ); assert_eq!( snap.background_tasks.len(), 1, "only the transitioned FG so far: {:?}", snap.background_tasks ); let bg = start_background_sleep(&session, out_dir.path(), "snap-bg-task").await; let snap = snapshot(&handler).await; assert_eq!( snap.background_tasks.len(), 2, "transitioned FG + incomplete BG must appear: {:?}", snap.background_tasks ); assert!( snap.background_tasks .iter() .any(|t| t.task_id == bg.task_id), "run_background task missing: {:?}", snap.background_tasks ); let short = session .terminal_backend() .run_background(terminal_run_request( "true", out_dir.path(), "snap-done-task", )) .await .expect("start short background task"); let done = session .terminal_backend() .wait_for_completion(&short.task_id, Some(Duration::from_secs(5))) .await .expect("short background task should complete"); assert!(done.completed, "short task must complete: {done:?}"); let listed = session.terminal_backend().list_tasks().await; assert!( listed .iter() .any(|t| t.task_id == short.task_id && t.completed && t.is_backgrounded), "precondition: completed BG must still be in list_tasks: {listed:?}" ); let snap = snapshot(&handler).await; assert!( snap.background_tasks .iter() .all(|t| t.task_id != short.task_id), "completed BG must not appear: {:?}", snap.background_tasks ); assert_eq!( snap.background_tasks.len(), 2, "still-running BG tasks remain: {:?}", snap.background_tasks ); assert!( snap.background_tasks .iter() .any(|t| t.task_id == bg.task_id), "run_background task should still be present: {:?}", snap.background_tasks ); assert!( snap.background_tasks .iter() .any(|t| t.task_id == "snap-fg-task"), "transitioned FG should still be present: {:?}", snap.background_tasks ); session.terminal_backend().kill_task(&bg.task_id).await; session.terminal_backend().kill_task("snap-fg-task").await; let _ = fg_join.await; } /// Evicting one session while another is live must NOT global-drain (which /// would close the shared queue for the survivor) — even when the evicted /// id is no longer in the session map. #[tokio::test] async fn handle_evict_keeps_queue_when_other_sessions_live() { let handle = make_handle(); handle .create_session("other") .expect("create second session"); let tracker = handle.activity_tracker().clone(); let handler = WorkspaceRpcHandler::new(handle); handler .handle_evict(ToolServerEvictParams { session_id: SessionId::new("ghost").expect("valid session id"), reason: "idle_timeout".into(), grace_period_ms: 200, }) .await; assert!( !tracker.is_draining(), "evict of an absent id with live sessions must not global-drain" ); } /// Evicting one of several live sessions removes *that* session (full /// teardown), keeps the survivors, and does not global-drain the shared /// queue. The drain decision is made on the post-removal map. #[tokio::test] async fn handle_evict_nonlast_removes_session_and_preserves_survivors() { let handle = make_handle(); handle .create_session("other") .expect("create second session"); let tracker = handle.activity_tracker().clone(); let handler = WorkspaceRpcHandler::new(handle.clone()); handler .handle_evict(ToolServerEvictParams { session_id: SessionId::new("other").expect("valid session id"), reason: "idle_timeout".into(), grace_period_ms: 200, }) .await; assert!( handle.session("other").is_none(), "the evicted session must be removed from the map" ); assert!( handle.session("main").is_some(), "a surviving session must be kept" ); assert!( !tracker.is_draining(), "evicting a non-last session must not global-drain the shared queue" ); } /// Once a terminal evict drain has started, a racing `bind`/create must be /// rejected so the shared upload queue is never torn down under a fresh /// session (race #3). #[tokio::test] async fn bind_rejected_after_evict_drain() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); handler .handle_evict(ToolServerEvictParams { session_id: SessionId::new("main").expect("valid session id"), reason: "preemption".into(), grace_period_ms: 100, }) .await; assert!(matches!( handle.create_session("late"), Err(WorkspaceError::ShuttingDown) )); } /// A duplicate / retried evict of the last session must not re-run the /// drain or downgrade terminal `ShuttingDown` back to `Draining`. #[tokio::test] async fn repeat_evict_does_not_redrain() { use xai_tool_protocol::ToolServerLifecycleStatus; let handle = make_handle(); let tracker = handle.activity_tracker().clone(); let handler = WorkspaceRpcHandler::new(handle); let params = || ToolServerEvictParams { session_id: SessionId::new("main").expect("valid session id"), reason: "preemption".into(), grace_period_ms: 100, }; handler.handle_evict(params()).await; assert_eq!( tracker.snapshot().status, ToolServerLifecycleStatus::ShuttingDown ); handler.handle_evict(params()).await; assert_eq!( tracker.snapshot().status, ToolServerLifecycleStatus::ShuttingDown, "a repeat evict must not downgrade terminal ShuttingDown to Draining" ); } #[tokio::test] async fn dispatch_tool_definitions_returns_known_tools() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({"session_id": "main"}); let result = handler .dispatch("workspace.tool_definitions", params, None) .await; let value = result.expect("should succeed"); let arr = value.as_array().expect("should be array"); assert!(!arr.is_empty(), "main session should have tools"); let names: Vec<&str> = arr .iter() .filter_map(|d| { d.get("function") .and_then(|f| f.get("name")) .and_then(|n| n.as_str()) }) .collect(); assert!( names.contains(&"read_file"), "should contain read_file: {names:?}" ); } #[tokio::test] async fn dispatch_tool_definitions_unknown_session() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({"session_id": "ghost"}); let result = handler .dispatch("workspace.tool_definitions", params, None) .await; assert!(matches!(result, Err(WorkspaceError::SessionNotFound(_)))); } #[tokio::test] async fn dispatch_get_all_hunks_returns_array() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.get_all_hunks", Value::Null, Some("main")) .await; assert!(result.is_ok()); assert!(result.unwrap().is_array()); } #[tokio::test] async fn dispatch_get_session_summary_returns_object_or_null() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.get_session_summary", Value::Null, Some("main")) .await; let value = result.expect("should succeed"); assert!( value.is_object() || value.is_null(), "expected object or null, got {value}" ); } #[tokio::test] async fn dispatch_discover_skills_returns_array() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.discover_skills", Value::Null, None) .await; assert!(result.is_ok()); assert!(result.unwrap().is_array()); } #[tokio::test] async fn dispatch_load_envrc_returns_object() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.load_envrc", Value::Null, None) .await; assert!(result.is_ok()); assert!(result.unwrap().is_object()); } #[tokio::test] async fn dispatch_drop_session_self_succeeds() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let params = serde_json::json!({"caller_session_id": "main", "session_id": "main"}); let result = handler .dispatch("workspace.drop_session", params, None) .await; assert!(result.is_ok(), "dropping own session should succeed"); assert!(handle.session("main").is_none(), "session should be gone"); } #[tokio::test] async fn dispatch_update_tool_config_missing_params() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.update_tool_config", serde_json::json!({}), None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing")), "got {result:?}" ); } fn caller_mismatch_count(method: &str, kind: &str) -> u64 { WORKSPACE_RPC_CALLER_MISMATCH_TOTAL .with_label_values(&[method, kind]) .get() } fn baseline_config_value() -> Value { serde_json::to_value(crate::session::tool_config::test_support::baseline_config()) .expect("baseline config serializes") } /// With both an envelope session and a (spoofed) param, the envelope /// wins: the call is authorized as the envelope session and the /// mismatch is counted. #[tokio::test] async fn dispatch_update_tool_config_envelope_overrides_param() { let mismatch_before = caller_mismatch_count("update_tool_config", "param_mismatch"); let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "caller_session_id": "spoofed", "session_id": "main", "new_config": baseline_config_value(), }); let result = handler .dispatch("workspace.update_tool_config", params, Some("main")) .await; assert!( result.is_ok(), "envelope caller == target must authorize: {result:?}" ); assert!( caller_mismatch_count("update_tool_config", "param_mismatch") > mismatch_before, "the param/envelope disagreement must be counted" ); } /// A forged `caller_session_id` param cannot authorize a cross-session /// mutation: the envelope session is the caller and differs from the /// target, so the target's caller-equals-target check rejects it. #[tokio::test] async fn dispatch_update_tool_config_envelope_cross_session_unauthorized() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let params = serde_json::json!({ "caller_session_id": "main", "session_id": "main", "new_config": baseline_config_value(), }); let result = handler .dispatch("workspace.update_tool_config", params, Some("other")) .await; assert!( matches!(result, Err(WorkspaceError::Unauthorized { .. })), "got {result:?}" ); assert!( handle.session("main").is_some(), "the target session must be untouched" ); } /// Compat: without an envelope session (old call paths) the param is /// still honored, and the fallback is counted for the deprecation /// monitor. #[tokio::test] async fn dispatch_update_tool_config_param_fallback_without_envelope() { let absent_before = caller_mismatch_count("update_tool_config", "envelope_absent"); let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "caller_session_id": "main", "session_id": "main", "new_config": baseline_config_value(), }); let result = handler .dispatch("workspace.update_tool_config", params, None) .await; assert!(result.is_ok(), "param fallback must authorize: {result:?}"); assert!( caller_mismatch_count("update_tool_config", "envelope_absent") > absent_before, "the envelope-absent fallback must be counted" ); } /// The intended steady state once clients drop the deprecated param: /// envelope-only identity (no `caller_session_id` in params) authorizes. /// Counter non-advance is asserted by /// [`resolve_mutation_caller_clean_arms_count_nothing`], which uses a /// test-unique method label — the real label is shared with concurrently /// running dispatch tests, so an equality assert here would flake. #[tokio::test] async fn dispatch_update_tool_config_envelope_only_without_param() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "session_id": "main", "new_config": baseline_config_value(), }); let result = handler .dispatch("workspace.update_tool_config", params, Some("main")) .await; assert!( result.is_ok(), "envelope-only identity must authorize: {result:?}" ); } /// The two clean `resolve_mutation_caller` arms — envelope-only and /// envelope+matching-param — resolve to the envelope without ticking /// either deprecation-monitor kind. #[test] fn resolve_mutation_caller_clean_arms_count_nothing() { const METHOD: &str = "test_clean_arms"; let mismatch_before = caller_mismatch_count(METHOD, "param_mismatch"); let absent_before = caller_mismatch_count(METHOD, "envelope_absent"); let caller = resolve_mutation_caller(METHOD, Some("sess"), None) .expect("envelope-only must resolve"); assert_eq!(caller, "sess"); let caller = resolve_mutation_caller(METHOD, Some("sess"), Some("sess")) .expect("matching param must resolve"); assert_eq!(caller, "sess"); assert_eq!( caller_mismatch_count(METHOD, "param_mismatch"), mismatch_before, "clean arms must not count a mismatch" ); assert_eq!( caller_mismatch_count(METHOD, "envelope_absent"), absent_before, "clean arms must not count an envelope-absent fallback" ); } /// `drop_session` gets the same envelope-derived identity: a spoofed /// param is ignored when the envelope authorizes the drop, and the /// mutation audit counter advances. #[tokio::test] async fn dispatch_drop_session_envelope_overrides_param() { let mutation_before = WORKSPACE_RPC_MUTATION_TOTAL .with_label_values(&["drop_session", "ok"]) .get(); let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let params = serde_json::json!({"caller_session_id": "spoofed", "session_id": "main"}); let result = handler .dispatch("workspace.drop_session", params, Some("main")) .await; assert!(result.is_ok(), "{result:?}"); assert!(handle.session("main").is_none(), "session should be gone"); assert!( WORKSPACE_RPC_MUTATION_TOTAL .with_label_values(&["drop_session", "ok"]) .get() > mutation_before, "the mutation audit counter must advance" ); } /// A cross-session drop forged via the param is rejected off the /// envelope identity and the target survives. #[tokio::test] async fn dispatch_drop_session_envelope_cross_session_unauthorized() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let params = serde_json::json!({"caller_session_id": "main", "session_id": "main"}); let result = handler .dispatch("workspace.drop_session", params, Some("observer-ish")) .await; assert!( matches!(result, Err(WorkspaceError::Unauthorized { .. })), "got {result:?}" ); assert!( handle.session("main").is_some(), "the target session must survive" ); } /// `configure_mcp`'s on-demand session create opts into system /// notifications, like every other sandbox-path creator. #[tokio::test] async fn dispatch_configure_mcp_on_demand_create_enables_system_notifications() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let _ = handler .dispatch( "workspace.configure_mcp", serde_json::json!({"mcp_servers": []}), Some("mcp-fresh"), ) .await; let session = handle .session("mcp-fresh") .expect("session created on demand"); assert!( session.system_notifications(), "the on-demand created session must forward system notifications" ); } #[tokio::test] async fn dispatch_hunk_action_unknown_action() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "action": {"hunk_id": "test-id", "action": "dance"} }); let result = handler .dispatch("workspace.hunk_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(_))), "expected HubError for invalid action enum, got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_action_malformed_json() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "action": "not-an-object" }); let result = handler .dispatch("workspace.hunk_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(_))), "expected HubError for malformed action, got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_action_missing_action_field() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({}); let result = handler .dispatch("workspace.hunk_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")), "got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_file_action_missing_path() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({"action": "accept"}); let result = handler .dispatch("workspace.hunk_file_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")), "got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_turn_action_missing_prompt_index() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({"action": "accept"}); let result = handler .dispatch("workspace.hunk_turn_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")), "got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_all_action_invalid_action() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({"action": "explode"}); let result = handler .dispatch("workspace.hunk_all_action", params, None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(_))), "expected HubError for invalid action enum, got {result:?}" ); } #[tokio::test] async fn dispatch_hunk_get_all_file_contents_returns_array() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch( "workspace.hunk_get_all_file_contents", Value::Null, Some("main"), ) .await; assert!(result.is_ok()); assert!(result.unwrap().is_array()); } #[tokio::test] async fn dispatch_hunk_get_staged_files_returns_array() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.hunk_get_staged_files", Value::Null, Some("main")) .await; assert!(result.is_ok()); assert!(result.unwrap().is_array()); } #[tokio::test] async fn dispatch_fuzzy_open_returns_search_id() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch( "workspace.fuzzy_open", serde_json::json!({"hidden": false}), None, ) .await; let value = result.expect("should succeed"); assert!( value.as_str().is_some_and(|s| !s.is_empty()), "response should be a non-empty search_id string: {value}" ); } #[tokio::test] async fn dispatch_fuzzy_close_unknown_id() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch( "workspace.fuzzy_close", serde_json::json!({"search_id": "nonexistent"}), None, ) .await; let value = result.expect("should succeed"); assert!(!value.as_bool().expect("response should be a bool")); } #[tokio::test] async fn dispatch_fuzzy_change_missing_search_id() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch( "workspace.fuzzy_change", serde_json::json!({"query": "test"}), None, ) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")), "got {result:?}" ); } #[tokio::test] async fn dispatch_fuzzy_search_missing_search_id() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.fuzzy_search", serde_json::json!({}), None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing search_id")), "got {result:?}" ); } #[tokio::test] async fn dispatch_fuzzy_open_then_close_roundtrip() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let open_result = handler .dispatch( "workspace.fuzzy_open", serde_json::json!({"hidden": false}), None, ) .await .expect("open should succeed"); let search_id = open_result .as_str() .expect("open response should be a search_id string") .to_owned(); let close_result = handler .dispatch( "workspace.fuzzy_close", serde_json::json!({"search_id": search_id}), None, ) .await .expect("close should succeed"); assert!( close_result .as_bool() .expect("close response should be a bool") ); let close_again = handler .dispatch( "workspace.fuzzy_close", serde_json::json!({"search_id": search_id}), None, ) .await .expect("close again should succeed"); assert!( !close_again .as_bool() .expect("close-again response should be a bool") ); } #[tokio::test] async fn handle_call_wraps_in_envelope_with_value() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let mut ctx = ToolCallContext::default(); ctx.extensions .insert(xai_tool_runtime::SessionContext("main".to_owned())); let args = serde_json::json!({ "method": "workspace.get_session_summary", "params": {} }); let mut stream = handler.handle_call(ctx, args).await; let item = next_item(&mut stream).await.expect("should have terminal"); match item { xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => { let ok_val = typed .value .get("ok") .expect("envelope should have 'ok' key"); assert!( ok_val.is_object() || ok_val.is_null(), "ok value should be object or null, got {ok_val}" ); } other => panic!("expected Terminal(Ok), got {other:?}"), } } #[tokio::test] async fn handle_call_error_envelope() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let ctx = ToolCallContext::default(); let args = serde_json::json!({ "method": "workspace.nonexistent", "params": {} }); let mut stream = handler.handle_call(ctx, args).await; let item = next_item(&mut stream).await.expect("should have terminal"); match item { xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => { assert!( typed.value.get("err").is_some(), "envelope should have 'err' key: {}", typed.value ); let err = typed.value.get("err").unwrap(); assert!(err.get("code").is_some()); assert!(err.get("message").is_some()); } other => panic!("expected Terminal(Ok(envelope)), got {other:?}"), } } /// `handle_call` records the RPC metrics: a known method increments its /// per-method `ok` series, and an unrecognized method collapses to /// `method="unknown",result="error"` — never creating a per-bad-method /// series (the cardinality-bounding guarantee). #[tokio::test] async fn handle_call_records_rpc_metrics_and_collapses_unknown_method() { let handler = WorkspaceRpcHandler::new(make_handle()); let ok_before = WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&["workspace.get_session_summary", "ok"]) .get(); let dur_samples_before = WORKSPACE_RPC_DURATION_SECONDS .with_label_values(&["workspace.get_session_summary"]) .get_sample_count(); let mut ctx = ToolCallContext::default(); ctx.extensions .insert(xai_tool_runtime::SessionContext("main".to_owned())); let mut stream = handler .handle_call( ctx, serde_json::json!({"method": "workspace.get_session_summary", "params": {}}), ) .await; let _ = next_item(&mut stream).await; assert!( WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&["workspace.get_session_summary", "ok"]) .get() > ok_before, "a known ok RPC must increment its per-method ok counter" ); assert!( WORKSPACE_RPC_DURATION_SECONDS .with_label_values(&["workspace.get_session_summary"]) .get_sample_count() > dur_samples_before, "the dispatch must observe the per-method duration histogram" ); const BOGUS: &str = "workspace.__test_bogus_method_zzz"; let unknown_before = WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "error"]) .get(); let kind_before = WORKSPACE_RPC_ERRORS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"]) .get(); let mut stream = handler .handle_call( ToolCallContext::default(), serde_json::json!({"method": BOGUS, "params": {}}), ) .await; let _ = next_item(&mut stream).await; assert!( WORKSPACE_RPC_REQUESTS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "error"]) .get() > unknown_before, "an unrecognized method must increment the collapsed unknown/error counter" ); assert!( WORKSPACE_RPC_ERRORS_TOTAL .with_label_values(&[UNKNOWN_METHOD_LABEL, "hub_error"]) .get() > kind_before, "a failed dispatch must also record its error_kind on the errors counter" ); let has_bogus_series = prometheus::gather() .iter() .filter(|mf| mf.name() == "grok_workspace_rpc_requests_total") .flat_map(|mf| mf.get_metric()) .any(|m| { m.get_label() .iter() .any(|l| l.name() == "method" && l.value() == BOGUS) }); assert!( !has_bogus_series, "the raw bad method must collapse to `unknown`, never its own series" ); } #[tokio::test] async fn dispatch_git_stage_non_git_dir_returns_error() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.git_stage", serde_json::json!({}), None) .await; assert!(result.is_err(), "non-git dir should error"); } #[tokio::test] async fn dispatch_git_commit_missing_message_returns_error() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.git_commit", serde_json::json!({}), None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")) ); } #[tokio::test] async fn dispatch_git_checkout_missing_branch_returns_error() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.git_checkout", serde_json::json!({}), None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing field")) ); } #[tokio::test] async fn dispatch_git_stage_content_missing_fields() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let result = handler .dispatch("workspace.git_stage_content", serde_json::json!({}), None) .await; assert!( matches!(result, Err(WorkspaceError::HubError(ref msg)) if msg.contains("missing")) ); } #[tokio::test] async fn handle_hook_before_turn_sets_turn_state() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let payload = turn_hook::BeforeTurnPayload { turn_number: 1, model_id: "grok-3".to_string(), yolo_mode: false, conversation_message_count: 0, session_relationship: "primary".to_string(), schema_version: "1.0".to_string(), }; let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event: HookEvent::Custom { kind: turn_hook::BEFORE_TURN_KIND.to_string(), payload: serde_json::to_value(&payload).unwrap(), }, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; let tracker = handle.activity_tracker(); assert!( tracker.known_sessions().contains(&"main".to_string()), "before_turn hook should create a session entry in the activity tracker" ); } #[tokio::test] async fn handle_hook_after_turn_does_not_panic() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); handle.activity_tracker().turn_started("main", 1); let payload = turn_hook::AfterTurnPayload { turn_number: 1, outcome: turn_hook::TurnHookOutcome::Completed, duration_ms: 500, tool_call_count: 3, model_id: "grok-3".to_string(), written_repo_paths: Vec::new(), cancellation_category: None, cancellation_context: None, }; let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event: HookEvent::Custom { kind: turn_hook::AFTER_TURN_KIND.to_string(), payload: serde_json::to_value(&payload).unwrap(), }, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; } #[tokio::test] async fn handle_hook_malformed_payload_does_not_panic() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event: HookEvent::Custom { kind: turn_hook::BEFORE_TURN_KIND.to_string(), payload: serde_json::json!({"garbage": true}), }, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; } #[tokio::test] async fn handle_hook_unrecognized_custom_kind_does_not_panic() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event: HookEvent::Custom { kind: "unknown_kind".to_string(), payload: serde_json::json!({}), }, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; } #[tokio::test] async fn handle_hook_cancel_marks_call_completed() { use xai_tool_protocol::ToolCallId; let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let tracker = handle.activity_tracker(); tracker.tool_call_started("call-42", "read_file", Some("main")); assert_eq!(tracker.snapshot().active_tool_calls, 1); let frame = HookFrame::cancel( SessionId::new("main").unwrap(), ToolId::new("read_file").unwrap(), ToolCallId::new("call-42").unwrap(), ); handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; assert_eq!( tracker.snapshot().active_tool_calls, 0, "cancel hook should mark the call as completed" ); } #[tokio::test] async fn handle_hook_cancel_without_call_id_cancels_all_session_calls() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let tracker = handle.activity_tracker(); tracker.tool_call_started("call-a", "grep", Some("main")); tracker.tool_call_started("call-b", "read_file", Some("main")); tracker.tool_call_started("call-c", "write", Some("other")); assert_eq!(tracker.snapshot().active_tool_calls, 3); let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event: HookEvent::Cancel, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; assert_eq!( tracker.snapshot_session("main").active_tool_calls, 0, "session-wide cancel should complete all calls for the session" ); assert_eq!( tracker.snapshot_session("other").active_tool_calls, 1, "cancel must not affect calls in other sessions" ); assert_eq!(tracker.snapshot().active_tool_calls, 1); } #[tokio::test] async fn handle_hook_session_ended_clears_turn_active() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle.clone()); let tracker = handle.activity_tracker(); tracker.turn_started("main", 1); assert!(tracker.is_turn_active("main")); let frame = HookFrame::session_ended(SessionId::new("main").unwrap()); handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; assert!( !tracker.is_turn_active("main"), "session_ended hook should clear turn_active" ); } use crate::workspace_ops::{GetFilesRes, PutFilesRes}; /// Helper: compute SHA-256 hex digest for test assertions. fn test_sha256(data: &[u8]) -> String { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(data)) } #[tokio::test] async fn dispatch_put_files_writes_and_returns_hash() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "files": [{"path": "test_file.txt", "content": "hello world"}] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].ok, "write should succeed"); let expected_hash = test_sha256(b"hello world"); assert_eq!( res.results[0].hash.as_deref(), Some(expected_hash.as_str()), "hash should be SHA-256 of written content" ); assert!(res.results[0].error.is_none(), "no error expected"); let on_disk = std::fs::read_to_string(root.join("test_file.txt")).unwrap(); assert_eq!(on_disk, "hello world"); } #[tokio::test] async fn dispatch_put_files_rejects_path_traversal() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "files": [{"path": "../escape.txt", "content": "evil"}] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch itself should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(!res.results[0].ok, "path traversal should be rejected"); assert!( res.results[0] .error .as_ref() .unwrap() .contains("escapes workspace root"), "error should mention escape: {:?}", res.results[0].error ); } #[tokio::test] async fn dispatch_resolve_file_references_rejects_outside_root_when_confined() { let handle = make_confining_handle(); let handler = WorkspaceRpcHandler::new(handle); let secret = std::env::temp_dir().join("h1_3885911_outside_secret.txt"); std::fs::write(&secret, "OUTSIDE_SECRET").unwrap(); let params = serde_json::json!({ "refs": [secret.to_string_lossy(), "../escape.txt"] }); let result = handler .dispatch("workspace.resolve_file_references", params, None) .await .expect("dispatch itself should succeed"); let arr = result.as_array().expect("results array"); assert_eq!(arr.len(), 2); for entry in arr { assert_eq!(entry["exists"], serde_json::Value::Bool(false)); assert_eq!(entry["content"], serde_json::Value::Null); assert!( entry["error"] .as_str() .unwrap_or_default() .contains("escapes workspace root"), "escape should be rejected, not read: {entry:?}" ); } std::fs::remove_file(&secret).ok(); } #[tokio::test] async fn handle_hook_pause_resume_are_noops() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); for event in [HookEvent::Pause, HookEvent::Resume] { let frame = HookFrame { session_id: SessionId::new("main").unwrap(), tool_id: None, call_id: None, hook_id: None, event, trace_context: None, }; handler .handle_hook(SessionId::new("main").unwrap(), frame) .await; } } #[tokio::test] async fn dispatch_put_files_rejects_absolute_outside_root() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "files": [{"path": "/etc/passwd", "content": "evil"}] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch itself should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!( !res.results[0].ok, "absolute path outside root should be rejected" ); assert!( res.results[0] .error .as_ref() .unwrap() .contains("escapes workspace root"), "error should mention escape: {:?}", res.results[0].error ); } #[tokio::test] async fn dispatch_put_files_accepts_absolute_within_root() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let abs = root.join("sub/abs.txt"); let params = serde_json::json!({ "files": [{"path": abs.to_str().expect("utf-8 path"), "content": "hello"}] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch itself should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!( res.results[0].ok, "absolute path within root should be accepted: {:?}", res.results[0].error ); assert_eq!( std::fs::read_to_string(root.join("sub/abs.txt")).unwrap(), "hello" ); } #[tokio::test] #[cfg(unix)] async fn dispatch_put_files_rejects_symlink_escape() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let outside = tempfile::tempdir().expect("create outside dir"); std::os::unix::fs::symlink(outside.path(), root.join("escape_link")) .expect("create symlink"); let params = serde_json::json!({ "files": [{"path": "escape_link/evil.txt", "content": "pwned"}] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch itself should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(!res.results[0].ok, "symlink escape should be rejected"); assert!( res.results[0] .error .as_ref() .unwrap() .contains("symlink escape"), "error should mention symlink: {:?}", res.results[0].error ); assert!( !outside.path().join("evil.txt").exists(), "file must not be created outside workspace" ); } #[tokio::test] async fn dispatch_put_files_partial_failure() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "files": [ {"path": "good.txt", "content": "valid content"}, {"path": "../bad.txt", "content": "should fail"}, ] }); let result = handler .dispatch("workspace.put_files", params, None) .await .expect("dispatch should succeed"); let res: PutFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 2); assert!(res.results[0].ok, "first file should succeed"); assert!(res.results[0].hash.is_some(), "first file should have hash"); assert!(!res.results[1].ok, "second file should fail"); assert!( res.results[1].error.is_some(), "second file should have error" ); assert!( res.results[1].hash.is_none(), "failed file should have no hash" ); let on_disk = std::fs::read_to_string(root.join("good.txt")).unwrap(); assert_eq!(on_disk, "valid content"); } #[tokio::test] async fn dispatch_get_files_reads_existing_file() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let content = "read me back"; std::fs::write(root.join("readable.txt"), content).unwrap(); let params = serde_json::json!({ "files": [{"path": "readable.txt"}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists, "file should exist"); assert_eq!( res.results[0].content.as_deref(), Some(content), "content should match what was written" ); let expected_hash = test_sha256(content.as_bytes()); assert_eq!( res.results[0].hash.as_deref(), Some(expected_hash.as_str()), "hash should be SHA-256 of file content" ); assert!(!res.results[0].matched); assert_eq!( res.results[0].size, Some(content.len() as u64), "size should match content length" ); assert!(res.results[0].error.is_none()); } #[tokio::test] async fn dispatch_get_files_nonexistent_returns_not_exists() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let params = serde_json::json!({ "files": [{"path": "does_not_exist.txt"}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(!res.results[0].exists, "file should not exist"); assert!(res.results[0].content.is_none()); assert!(res.results[0].hash.is_none()); assert!(!res.results[0].matched); assert!( res.results[0].error.is_none(), "missing file is not an error" ); } #[tokio::test] async fn dispatch_get_files_io_error_returns_exists_true() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); std::fs::create_dir_all(root.join("a_directory")).unwrap(); let params = serde_json::json!({ "files": [{"path": "a_directory"}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists, "directory exists on disk"); assert!( res.results[0].error.is_some(), "reading a directory as file should fail: {:?}", res.results[0] ); assert!(res.results[0].content.is_none(), "no content on error"); } #[tokio::test] async fn dispatch_get_files_non_utf8_returns_error_with_hash() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let binary_content: &[u8] = b"\xff\xfe\x00\x01"; std::fs::write(root.join("binary.bin"), binary_content).unwrap(); let params = serde_json::json!({ "files": [{"path": "binary.bin"}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists, "file should exist"); assert!( res.results[0].content.is_none(), "non-UTF-8 content should be None" ); let expected_hash = test_sha256(binary_content); assert_eq!( res.results[0].hash.as_deref(), Some(expected_hash.as_str()), "hash should be SHA-256 of file content even for non-UTF-8 files" ); assert!( res.results[0] .error .as_ref() .unwrap() .contains("not valid UTF-8"), "error should mention UTF-8: {:?}", res.results[0].error ); assert_eq!( res.results[0].size, Some(4), "size should still be reported" ); } #[tokio::test] async fn dispatch_get_files_cache_hit() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let content = "cacheable content"; std::fs::write(root.join("cached.txt"), content).unwrap(); let expected_hash = test_sha256(content.as_bytes()); let params = serde_json::json!({ "files": [{"path": "cached.txt", "if_none_match": expected_hash}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists); assert!(res.results[0].matched, "should be a cache hit"); assert!( res.results[0].content.is_none(), "content should be omitted on cache hit" ); assert_eq!( res.results[0].hash.as_deref(), Some(expected_hash.as_str()), "hash should still be returned" ); assert!(res.results[0].error.is_none()); } #[tokio::test] async fn dispatch_get_files_cache_miss() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let content = "fresh content"; std::fs::write(root.join("stale.txt"), content).unwrap(); let params = serde_json::json!({ "files": [{"path": "stale.txt", "if_none_match": "0000000000000000000000000000000000000000000000000000000000000000"}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists); assert!(!res.results[0].matched, "should be a cache miss"); assert_eq!( res.results[0].content.as_deref(), Some(content), "content should be returned on miss" ); let expected_hash = test_sha256(content.as_bytes()); assert_eq!( res.results[0].hash.as_deref(), Some(expected_hash.as_str()), "current hash should be returned" ); } #[tokio::test] async fn dispatch_put_then_get_round_trip() { let handle = make_handle(); let handler = WorkspaceRpcHandler::new(handle); let content = "round trip content"; let put_params = serde_json::json!({ "files": [{"path": "round_trip.txt", "content": content}] }); let put_result = handler .dispatch("workspace.put_files", put_params, None) .await .expect("put should succeed"); let put_res: PutFilesRes = serde_json::from_value(put_result).unwrap(); assert!(put_res.results[0].ok); let put_hash = put_res.results[0].hash.clone().unwrap(); let get_params = serde_json::json!({ "files": [{"path": "round_trip.txt"}] }); let get_result = handler .dispatch("workspace.get_files", get_params, None) .await .expect("get should succeed"); let get_res: GetFilesRes = serde_json::from_value(get_result).unwrap(); assert!(get_res.results[0].exists); assert_eq!( get_res.results[0].content.as_deref(), Some(content), "content should match what was written" ); assert_eq!( get_res.results[0].hash.as_deref(), Some(put_hash.as_str()), "get hash should match put hash" ); } #[tokio::test] async fn dispatch_put_files_append_mode() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let params1 = serde_json::json!({ "files": [{"path": "chunked.txt", "content": "hello", "append": false}] }); let res1 = handler .dispatch("workspace.put_files", params1, None) .await .expect("first chunk should succeed"); let put1: PutFilesRes = serde_json::from_value(res1).unwrap(); assert!(put1.results[0].ok); let chunk1_hash = put1.results[0].hash.clone().unwrap(); assert_eq!( chunk1_hash, test_sha256(b"hello"), "hash should be of the appended chunk, not full file" ); let params2 = serde_json::json!({ "files": [{"path": "chunked.txt", "content": " world", "append": true}] }); let res2 = handler .dispatch("workspace.put_files", params2, None) .await .expect("second chunk should succeed"); let put2: PutFilesRes = serde_json::from_value(res2).unwrap(); assert!(put2.results[0].ok); let chunk2_hash = put2.results[0].hash.clone().unwrap(); assert_eq!( chunk2_hash, test_sha256(b" world"), "hash should be of the appended chunk only" ); let on_disk = std::fs::read_to_string(root.join("chunked.txt")).unwrap(); assert_eq!(on_disk, "hello world"); } #[tokio::test] async fn dispatch_get_files_byte_range() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let content = "0123456789"; std::fs::write(root.join("range.txt"), content).unwrap(); let params = serde_json::json!({ "files": [{"path": "range.txt", "offset": 3, "length": 4}] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists); assert_eq!( res.results[0].content.as_deref(), Some("3456"), "should return only the requested byte range" ); let full_hash = test_sha256(content.as_bytes()); assert_eq!( res.results[0].hash.as_deref(), Some(full_hash.as_str()), "hash should be of the full file, not the chunk" ); assert!(!res.results[0].matched); assert_eq!( res.results[0].size, Some(content.len() as u64), "size should be full file size" ); } #[tokio::test] async fn dispatch_get_files_byte_range_cache_hit() { let handle = make_handle(); let root = handle.root_cwd().unwrap(); let handler = WorkspaceRpcHandler::new(handle); let content = "abcdefghij"; std::fs::write(root.join("range_cache.txt"), content).unwrap(); let full_hash = test_sha256(content.as_bytes()); let params = serde_json::json!({ "files": [{ "path": "range_cache.txt", "offset": 2, "length": 3, "if_none_match": full_hash, }] }); let result = handler .dispatch("workspace.get_files", params, None) .await .expect("dispatch should succeed"); let res: GetFilesRes = serde_json::from_value(result).unwrap(); assert_eq!(res.results.len(), 1); assert!(res.results[0].exists); assert!(res.results[0].matched, "should be a cache hit"); assert!( res.results[0].content.is_none(), "content should be omitted on cache hit" ); assert_eq!( res.results[0].hash.as_deref(), Some(full_hash.as_str()), "hash should still be returned" ); assert_eq!(res.results[0].size, Some(10)); } /// Every type with a `WorkspaceRpc` impl must be routed by `dispatch()`. /// /// Each entry is compiler-checked via `::METHOD`. /// Dispatching `{}` may fail with any per-method error (invalid params, /// session not found, not a git repo) — only an "unknown workspace /// method" error fails the test. #[tokio::test] async fn dispatch_knows_every_typed_method() { use crate::file_system::{ ContentSearchRequest, FsDeleteFileReq, FsExistsReq, FsListReq, FsReadFileReq, FsWriteFileReq, }; use crate::workspace_ops::*; use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest}; use xai_grok_workspace_types::rpc::git::{GitBranchInfoReq, GitMetadataReq}; use xai_grok_workspace_types::rpc::search::FuzzyStatusReq; use xai_grok_workspace_types::rpc::skills::DiscoverPluginsReq; use xai_grok_workspace_types::rpc::workspace::{ ConfigureMcpReq, DropSessionReq, InstallPluginReq, LoadEnvrcReq, LoadPermissionsReq, LoadProjectConfigReq, RefreshPluginsReq, ResolveFileReferencesReq, ToolDefinitionsReq, UpdateToolConfigReq, }; use xai_grok_workspace_types::rpc::worktree::WorktreeCreateSyncReq; let handler = WorkspaceRpcHandler::new(make_handle()); let methods = [ ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ::METHOD, ]; let skipped_global_db_mutators = [ ::METHOD, ::METHOD, ]; assert_eq!(skipped_global_db_mutators.len(), 2); for method in methods { let result = handler.dispatch(method, serde_json::json!({}), None).await; if let Err(e) = &result { assert!( !e.to_string().contains("unknown workspace method"), "dispatch does not know {method}: {e}" ); } } } }