use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use tonic::{Request, Response, Status}; use wfe_server_protos::wfe::v1::*; use wfe_server_protos::wfe::v1::wfe_server::Wfe; pub struct WfeService { host: Arc, lifecycle_bus: Arc, log_store: Arc, log_search: Option>, } impl WfeService { pub fn new( host: Arc, lifecycle_bus: Arc, log_store: Arc, ) -> Self { Self { host, lifecycle_bus, log_store, log_search: None } } pub fn with_log_search(mut self, index: Arc) -> Self { self.log_search = Some(index); self } } #[tonic::async_trait] impl Wfe for WfeService { // ── Definitions ────────────────────────────────────────────────── async fn register_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let config: HashMap = req .config .into_iter() .map(|(k, v)| (k, serde_json::Value::String(v))) .collect(); let workflows = wfe_yaml::load_workflow_from_str(&req.yaml, &config) .map_err(|e| Status::invalid_argument(format!("YAML compilation failed: {e}")))?; let mut definitions = Vec::new(); for compiled in workflows { for (key, factory) in compiled.step_factories { self.host.register_step_factory(&key, factory).await; } let id = compiled.definition.id.clone(); let version = compiled.definition.version; let step_count = compiled.definition.steps.len() as u32; self.host .register_workflow_definition(compiled.definition) .await; definitions.push(RegisteredDefinition { definition_id: id, version, step_count, }); } Ok(Response::new(RegisterWorkflowResponse { definitions })) } async fn list_definitions( &self, _request: Request, ) -> Result, Status> { // TODO: add list_definitions() to WorkflowHost Ok(Response::new(ListDefinitionsResponse { definitions: vec![], })) } // ── Instances ──────────────────────────────────────────────────── async fn start_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let data = req .data .map(struct_to_json) .unwrap_or_else(|| serde_json::json!({})); let workflow_id = self .host .start_workflow(&req.definition_id, req.version, data) .await .map_err(|e| Status::internal(format!("failed to start workflow: {e}")))?; Ok(Response::new(StartWorkflowResponse { workflow_id })) } async fn get_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let instance = self .host .get_workflow(&req.workflow_id) .await .map_err(|e| Status::not_found(format!("workflow not found: {e}")))?; Ok(Response::new(GetWorkflowResponse { instance: Some(workflow_to_proto(&instance)), })) } async fn cancel_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); self.host .terminate_workflow(&req.workflow_id) .await .map_err(|e| Status::internal(format!("failed to cancel: {e}")))?; Ok(Response::new(CancelWorkflowResponse {})) } async fn suspend_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); self.host .suspend_workflow(&req.workflow_id) .await .map_err(|e| Status::internal(format!("failed to suspend: {e}")))?; Ok(Response::new(SuspendWorkflowResponse {})) } async fn resume_workflow( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); self.host .resume_workflow(&req.workflow_id) .await .map_err(|e| Status::internal(format!("failed to resume: {e}")))?; Ok(Response::new(ResumeWorkflowResponse {})) } async fn search_workflows( &self, _request: Request, ) -> Result, Status> { // TODO: implement with SearchIndex Ok(Response::new(SearchWorkflowsResponse { results: vec![], total: 0, })) } // ── Events ─────────────────────────────────────────────────────── async fn publish_event( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let data = req .data .map(struct_to_json) .unwrap_or_else(|| serde_json::json!({})); self.host .publish_event(&req.event_name, &req.event_key, data) .await .map_err(|e| Status::internal(format!("failed to publish event: {e}")))?; Ok(Response::new(PublishEventResponse { event_id: String::new(), })) } // ── Streaming (stubs for now) ──────────────────────────────────── type WatchLifecycleStream = tokio_stream::wrappers::ReceiverStream>; async fn watch_lifecycle( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let filter_workflow_id = if req.workflow_id.is_empty() { None } else { Some(req.workflow_id) }; let mut broadcast_rx = self.lifecycle_bus.subscribe(); let (tx, rx) = tokio::sync::mpsc::channel(256); tokio::spawn(async move { loop { match broadcast_rx.recv().await { Ok(event) => { // Apply workflow_id filter. if let Some(ref filter) = filter_workflow_id { if event.workflow_instance_id != *filter { continue; } } let proto_event = lifecycle_event_to_proto(&event); if tx.send(Ok(proto_event)).await.is_err() { break; // Client disconnected. } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::warn!(lagged = n, "lifecycle watcher lagged, skipping events"); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx))) } type StreamLogsStream = tokio_stream::wrappers::ReceiverStream>; async fn stream_logs( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let workflow_id = req.workflow_id.clone(); let step_name_filter = if req.step_name.is_empty() { None } else { Some(req.step_name) }; let (tx, rx) = tokio::sync::mpsc::channel(256); let log_store = self.log_store.clone(); tokio::spawn(async move { // 1. Replay history first. let history = log_store.get_history(&workflow_id, None); for chunk in history { if let Some(ref filter) = step_name_filter { if chunk.step_name != *filter { continue; } } let entry = log_chunk_to_proto(&chunk); if tx.send(Ok(entry)).await.is_err() { return; // Client disconnected. } } // 2. If follow mode, switch to live broadcast. if req.follow { let mut broadcast_rx = log_store.subscribe(&workflow_id); loop { match broadcast_rx.recv().await { Ok(chunk) => { if let Some(ref filter) = step_name_filter { if chunk.step_name != *filter { continue; } } let entry = log_chunk_to_proto(&chunk); if tx.send(Ok(entry)).await.is_err() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::warn!(lagged = n, "log stream lagged"); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } } // If not follow mode, the stream ends after history replay. }); Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx))) } // ── Search ─────────────────────────────────────────────────────── async fn search_logs( &self, request: Request, ) -> Result, Status> { let Some(ref search) = self.log_search else { return Err(Status::unavailable("log search not configured — set --search-url")); }; let req = request.into_inner(); let workflow_id = if req.workflow_id.is_empty() { None } else { Some(req.workflow_id.as_str()) }; let step_name = if req.step_name.is_empty() { None } else { Some(req.step_name.as_str()) }; let stream_filter = match req.stream_filter { x if x == LogStream::Stdout as i32 => Some("stdout"), x if x == LogStream::Stderr as i32 => Some("stderr"), _ => None, }; let take = if req.take == 0 { 50 } else { req.take }; let (hits, total) = search .search(&req.query, workflow_id, step_name, stream_filter, req.skip, take) .await .map_err(|e| Status::internal(format!("search failed: {e}")))?; let results = hits .into_iter() .map(|h| { let stream = match h.stream.as_str() { "stdout" => LogStream::Stdout as i32, "stderr" => LogStream::Stderr as i32, _ => LogStream::Unspecified as i32, }; LogSearchResult { workflow_id: h.workflow_id, definition_id: h.definition_id, step_name: h.step_name, line: h.line, stream, timestamp: Some(datetime_to_timestamp(&h.timestamp)), } }) .collect(); Ok(Response::new(SearchLogsResponse { results, total })) } } // ── Conversion helpers ────────────────────────────────────────────── fn struct_to_json(s: prost_types::Struct) -> serde_json::Value { let map: serde_json::Map = s .fields .into_iter() .map(|(k, v)| (k, prost_value_to_json(v))) .collect(); serde_json::Value::Object(map) } fn prost_value_to_json(v: prost_types::Value) -> serde_json::Value { use prost_types::value::Kind; match v.kind { Some(Kind::NullValue(_)) => serde_json::Value::Null, Some(Kind::NumberValue(n)) => serde_json::json!(n), Some(Kind::StringValue(s)) => serde_json::Value::String(s), Some(Kind::BoolValue(b)) => serde_json::Value::Bool(b), Some(Kind::StructValue(s)) => struct_to_json(s), Some(Kind::ListValue(l)) => { serde_json::Value::Array(l.values.into_iter().map(prost_value_to_json).collect()) } None => serde_json::Value::Null, } } fn json_to_struct(v: &serde_json::Value) -> prost_types::Struct { let fields: BTreeMap = match v.as_object() { Some(obj) => obj .iter() .map(|(k, v)| (k.clone(), json_to_prost_value(v))) .collect(), None => BTreeMap::new(), }; prost_types::Struct { fields } } fn json_to_prost_value(v: &serde_json::Value) -> prost_types::Value { use prost_types::value::Kind; let kind = match v { serde_json::Value::Null => Kind::NullValue(0), serde_json::Value::Bool(b) => Kind::BoolValue(*b), serde_json::Value::Number(n) => Kind::NumberValue(n.as_f64().unwrap_or(0.0)), serde_json::Value::String(s) => Kind::StringValue(s.clone()), serde_json::Value::Array(arr) => Kind::ListValue(prost_types::ListValue { values: arr.iter().map(json_to_prost_value).collect(), }), serde_json::Value::Object(_) => Kind::StructValue(json_to_struct(v)), }; prost_types::Value { kind: Some(kind) } } fn log_chunk_to_proto(chunk: &wfe_core::traits::LogChunk) -> LogEntry { use wfe_core::traits::LogStreamType; let stream = match chunk.stream { LogStreamType::Stdout => LogStream::Stdout as i32, LogStreamType::Stderr => LogStream::Stderr as i32, }; LogEntry { workflow_id: chunk.workflow_id.clone(), step_name: chunk.step_name.clone(), step_id: chunk.step_id as u32, stream, data: chunk.data.clone(), timestamp: Some(datetime_to_timestamp(&chunk.timestamp)), } } fn lifecycle_event_to_proto(e: &wfe_core::models::LifecycleEvent) -> LifecycleEvent { use wfe_core::models::LifecycleEventType as LET; // Proto enum — prost strips the LIFECYCLE_EVENT_TYPE_ prefix. use wfe_server_protos::wfe::v1::LifecycleEventType as PLET; let (event_type, step_id, step_name, error_message) = match &e.event_type { LET::Started => (PLET::Started as i32, 0, String::new(), String::new()), LET::Completed => (PLET::Completed as i32, 0, String::new(), String::new()), LET::Terminated => (PLET::Terminated as i32, 0, String::new(), String::new()), LET::Suspended => (PLET::Suspended as i32, 0, String::new(), String::new()), LET::Resumed => (PLET::Resumed as i32, 0, String::new(), String::new()), LET::Error { message } => (PLET::Error as i32, 0, String::new(), message.clone()), LET::StepStarted { step_id, step_name } => (PLET::StepStarted as i32, *step_id as u32, step_name.clone().unwrap_or_default(), String::new()), LET::StepCompleted { step_id, step_name } => (PLET::StepCompleted as i32, *step_id as u32, step_name.clone().unwrap_or_default(), String::new()), }; LifecycleEvent { event_time: Some(datetime_to_timestamp(&e.event_time_utc)), workflow_id: e.workflow_instance_id.clone(), definition_id: e.workflow_definition_id.clone(), version: e.version, event_type, step_id, step_name, error_message, } } fn datetime_to_timestamp(dt: &chrono::DateTime) -> prost_types::Timestamp { prost_types::Timestamp { seconds: dt.timestamp(), nanos: dt.timestamp_subsec_nanos() as i32, } } fn workflow_to_proto(w: &wfe_core::models::WorkflowInstance) -> WorkflowInstance { WorkflowInstance { id: w.id.clone(), definition_id: w.workflow_definition_id.clone(), version: w.version, description: w.description.clone().unwrap_or_default(), reference: w.reference.clone().unwrap_or_default(), status: match w.status { wfe_core::models::WorkflowStatus::Runnable => WorkflowStatus::Runnable as i32, wfe_core::models::WorkflowStatus::Suspended => WorkflowStatus::Suspended as i32, wfe_core::models::WorkflowStatus::Complete => WorkflowStatus::Complete as i32, wfe_core::models::WorkflowStatus::Terminated => WorkflowStatus::Terminated as i32, }, data: Some(json_to_struct(&w.data)), create_time: Some(datetime_to_timestamp(&w.create_time)), complete_time: w.complete_time.as_ref().map(datetime_to_timestamp), execution_pointers: w .execution_pointers .iter() .map(pointer_to_proto) .collect(), } } fn pointer_to_proto(p: &wfe_core::models::ExecutionPointer) -> ExecutionPointer { use wfe_core::models::PointerStatus as PS; let status = match p.status { PS::Pending | PS::PendingPredecessor => PointerStatus::Pending as i32, PS::Running => PointerStatus::Running as i32, PS::Complete => PointerStatus::Complete as i32, PS::Sleeping => PointerStatus::Sleeping as i32, PS::WaitingForEvent => PointerStatus::WaitingForEvent as i32, PS::Failed => PointerStatus::Failed as i32, PS::Skipped => PointerStatus::Skipped as i32, PS::Compensated | PS::Cancelled => PointerStatus::Cancelled as i32, }; ExecutionPointer { id: p.id.clone(), step_id: p.step_id as u32, step_name: p.step_name.clone().unwrap_or_default(), status, start_time: p.start_time.as_ref().map(datetime_to_timestamp), end_time: p.end_time.as_ref().map(datetime_to_timestamp), retry_count: p.retry_count, active: p.active, } } #[cfg(test)] mod tests { use super::*; #[test] fn struct_to_json_roundtrip() { let original = serde_json::json!({ "name": "test", "count": 42.0, "active": true, "tags": ["a", "b"], "nested": { "key": "value" } }); let proto_struct = json_to_struct(&original); let back = struct_to_json(proto_struct); assert_eq!(original, back); } #[test] fn json_null_roundtrip() { let v = serde_json::Value::Null; let pv = json_to_prost_value(&v); let back = prost_value_to_json(pv); assert_eq!(back, serde_json::Value::Null); } #[test] fn json_string_roundtrip() { let v = serde_json::Value::String("hello".to_string()); let pv = json_to_prost_value(&v); let back = prost_value_to_json(pv); assert_eq!(back, v); } #[test] fn json_bool_roundtrip() { let v = serde_json::Value::Bool(true); let pv = json_to_prost_value(&v); let back = prost_value_to_json(pv); assert_eq!(back, v); } #[test] fn json_number_roundtrip() { let v = serde_json::json!(3.14); let pv = json_to_prost_value(&v); let back = prost_value_to_json(pv); assert_eq!(back, v); } #[test] fn json_array_roundtrip() { let v = serde_json::json!(["a", 1.0, true, null]); let pv = json_to_prost_value(&v); let back = prost_value_to_json(pv); assert_eq!(back, v); } #[test] fn empty_struct_roundtrip() { let v = serde_json::json!({}); let proto_struct = json_to_struct(&v); let back = struct_to_json(proto_struct); assert_eq!(back, v); } #[test] fn prost_value_none_kind() { let v = prost_types::Value { kind: None }; assert_eq!(prost_value_to_json(v), serde_json::Value::Null); } #[test] fn json_to_struct_from_non_object() { let v = serde_json::json!("not an object"); let s = json_to_struct(&v); assert!(s.fields.is_empty()); } #[test] fn datetime_to_timestamp_conversion() { let dt = chrono::DateTime::parse_from_rfc3339("2026-03-29T12:00:00Z") .unwrap() .with_timezone(&chrono::Utc); let ts = datetime_to_timestamp(&dt); assert_eq!(ts.seconds, dt.timestamp()); assert_eq!(ts.nanos, 0); } #[test] fn workflow_status_mapping() { use wfe_core::models::{WorkflowInstance as WI, WorkflowStatus as WS}; let mut w = WI::new("test", 1, serde_json::json!({})); w.status = WS::Runnable; let p = workflow_to_proto(&w); assert_eq!(p.status, WorkflowStatus::Runnable as i32); w.status = WS::Complete; let p = workflow_to_proto(&w); assert_eq!(p.status, WorkflowStatus::Complete as i32); w.status = WS::Suspended; let p = workflow_to_proto(&w); assert_eq!(p.status, WorkflowStatus::Suspended as i32); w.status = WS::Terminated; let p = workflow_to_proto(&w); assert_eq!(p.status, WorkflowStatus::Terminated as i32); } #[test] fn pointer_status_mapping() { use wfe_core::models::{ExecutionPointer as EP, PointerStatus as PS}; let mut p = EP::new(0); p.status = PS::Pending; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Pending as i32); p.status = PS::Running; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Running as i32); p.status = PS::Complete; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Complete as i32); p.status = PS::Sleeping; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Sleeping as i32); p.status = PS::WaitingForEvent; assert_eq!(pointer_to_proto(&p).status, PointerStatus::WaitingForEvent as i32); p.status = PS::Failed; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Failed as i32); p.status = PS::Skipped; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Skipped as i32); p.status = PS::Cancelled; assert_eq!(pointer_to_proto(&p).status, PointerStatus::Cancelled as i32); } #[test] fn workflow_to_proto_basic() { let w = wfe_core::models::WorkflowInstance::new("my-wf", 1, serde_json::json!({"key": "val"})); let p = workflow_to_proto(&w); assert_eq!(p.definition_id, "my-wf"); assert_eq!(p.version, 1); assert!(p.create_time.is_some()); assert!(p.complete_time.is_none()); let data = struct_to_json(p.data.unwrap()); assert_eq!(data["key"], "val"); } // ── gRPC integration tests with real WorkflowHost ──────────────── async fn make_test_service() -> WfeService { use wfe::WorkflowHostBuilder; use wfe_core::test_support::{ InMemoryLockProvider, InMemoryPersistenceProvider, InMemoryQueueProvider, }; let host = WorkflowHostBuilder::new() .use_persistence(std::sync::Arc::new(InMemoryPersistenceProvider::new()) as std::sync::Arc) .use_lock_provider(std::sync::Arc::new(InMemoryLockProvider::new()) as std::sync::Arc) .use_queue_provider(std::sync::Arc::new(InMemoryQueueProvider::new()) as std::sync::Arc) .build() .unwrap(); host.start().await.unwrap(); let lifecycle_bus = std::sync::Arc::new(crate::lifecycle_bus::BroadcastLifecyclePublisher::new(64)); let log_store = std::sync::Arc::new(crate::log_store::LogStore::new()); WfeService::new(std::sync::Arc::new(host), lifecycle_bus, log_store) } #[tokio::test] async fn rpc_register_and_start_workflow() { let svc = make_test_service().await; // Register a workflow. let req = Request::new(RegisterWorkflowRequest { yaml: r#" workflow: id: test-wf version: 1 steps: - name: hello type: shell config: run: echo hi "#.to_string(), config: Default::default(), }); let resp = svc.register_workflow(req).await.unwrap().into_inner(); assert_eq!(resp.definitions.len(), 1); assert_eq!(resp.definitions[0].definition_id, "test-wf"); assert_eq!(resp.definitions[0].version, 1); assert_eq!(resp.definitions[0].step_count, 1); // Start the workflow. let req = Request::new(StartWorkflowRequest { definition_id: "test-wf".to_string(), version: 1, data: None, }); let resp = svc.start_workflow(req).await.unwrap().into_inner(); assert!(!resp.workflow_id.is_empty()); // Get the workflow. let req = Request::new(GetWorkflowRequest { workflow_id: resp.workflow_id.clone(), }); let resp = svc.get_workflow(req).await.unwrap().into_inner(); let instance = resp.instance.unwrap(); assert_eq!(instance.definition_id, "test-wf"); assert_eq!(instance.status, WorkflowStatus::Runnable as i32); } #[tokio::test] async fn rpc_register_invalid_yaml() { let svc = make_test_service().await; let req = Request::new(RegisterWorkflowRequest { yaml: "not: valid: yaml: {{{}}}".to_string(), config: Default::default(), }); let err = svc.register_workflow(req).await.unwrap_err(); assert_eq!(err.code(), tonic::Code::InvalidArgument); } #[tokio::test] async fn rpc_start_nonexistent_workflow() { let svc = make_test_service().await; let req = Request::new(StartWorkflowRequest { definition_id: "nonexistent".to_string(), version: 1, data: None, }); let err = svc.start_workflow(req).await.unwrap_err(); assert_eq!(err.code(), tonic::Code::Internal); } #[tokio::test] async fn rpc_get_nonexistent_workflow() { let svc = make_test_service().await; let req = Request::new(GetWorkflowRequest { workflow_id: "nonexistent".to_string(), }); let err = svc.get_workflow(req).await.unwrap_err(); assert_eq!(err.code(), tonic::Code::NotFound); } #[tokio::test] async fn rpc_cancel_workflow() { let svc = make_test_service().await; // Register + start. let req = Request::new(RegisterWorkflowRequest { yaml: "workflow:\n id: cancel-test\n version: 1\n steps:\n - name: s\n type: shell\n config:\n run: echo ok\n".to_string(), config: Default::default(), }); svc.register_workflow(req).await.unwrap(); let req = Request::new(StartWorkflowRequest { definition_id: "cancel-test".to_string(), version: 1, data: None, }); let wf_id = svc.start_workflow(req).await.unwrap().into_inner().workflow_id; // Cancel it. let req = Request::new(CancelWorkflowRequest { workflow_id: wf_id.clone() }); svc.cancel_workflow(req).await.unwrap(); // Verify it's terminated. let req = Request::new(GetWorkflowRequest { workflow_id: wf_id }); let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap(); assert_eq!(instance.status, WorkflowStatus::Terminated as i32); } #[tokio::test] async fn rpc_suspend_resume_workflow() { let svc = make_test_service().await; let req = Request::new(RegisterWorkflowRequest { yaml: "workflow:\n id: sr-test\n version: 1\n steps:\n - name: s\n type: shell\n config:\n run: echo ok\n".to_string(), config: Default::default(), }); svc.register_workflow(req).await.unwrap(); let req = Request::new(StartWorkflowRequest { definition_id: "sr-test".to_string(), version: 1, data: None, }); let wf_id = svc.start_workflow(req).await.unwrap().into_inner().workflow_id; // Suspend. let req = Request::new(SuspendWorkflowRequest { workflow_id: wf_id.clone() }); svc.suspend_workflow(req).await.unwrap(); let req = Request::new(GetWorkflowRequest { workflow_id: wf_id.clone() }); let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap(); assert_eq!(instance.status, WorkflowStatus::Suspended as i32); // Resume. let req = Request::new(ResumeWorkflowRequest { workflow_id: wf_id.clone() }); svc.resume_workflow(req).await.unwrap(); let req = Request::new(GetWorkflowRequest { workflow_id: wf_id }); let instance = svc.get_workflow(req).await.unwrap().into_inner().instance.unwrap(); assert_eq!(instance.status, WorkflowStatus::Runnable as i32); } #[tokio::test] async fn rpc_publish_event() { let svc = make_test_service().await; let req = Request::new(PublishEventRequest { event_name: "test.event".to_string(), event_key: "key-1".to_string(), data: None, }); // Should succeed even with no waiting workflows. svc.publish_event(req).await.unwrap(); } #[tokio::test] async fn rpc_search_logs_not_configured() { let svc = make_test_service().await; let req = Request::new(SearchLogsRequest { query: "test".to_string(), ..Default::default() }); let err = svc.search_logs(req).await.unwrap_err(); assert_eq!(err.code(), tonic::Code::Unavailable); } #[tokio::test] async fn rpc_list_definitions_empty() { let svc = make_test_service().await; let req = Request::new(ListDefinitionsRequest {}); let resp = svc.list_definitions(req).await.unwrap().into_inner(); assert!(resp.definitions.is_empty()); } #[tokio::test] async fn rpc_search_workflows_empty() { let svc = make_test_service().await; let req = Request::new(SearchWorkflowsRequest { query: "test".to_string(), ..Default::default() }); let resp = svc.search_workflows(req).await.unwrap().into_inner(); assert_eq!(resp.total, 0); } }