Skip to main content

aion_server/api/grpc/
mod.rs

1//! tonic workflow service adapter.
2
3/// Caller-identity extraction from gRPC request metadata.
4mod auth;
5/// Codec conversions between the generated wire messages and `aion-proto` types.
6/// Proto/generated wire conversions. Crate-visible because the HTTP edge
7/// reuses the rename conversions when it forwards to a shard owner (#211).
8pub(crate) mod convert;
9/// The owner side of namespace-mint routing (`MintNamespace`).
10mod mint_resolve;
11/// The pure-delegation RPC bodies (no routing, no forwarding, no cfg arms).
12mod plain_rpcs;
13/// Cluster request routing/resolution (steered start + forward-or-local).
14#[cfg(feature = "haematite-backend")]
15mod routing_resolve;
16/// Wire-error-to-tonic-Status mapping.
17mod status;
18
19pub(crate) use auth::caller_from_metadata;
20pub(crate) use status::{status_from_wire_error, status_with_code};
21
22use aion_proto::generated::{self, workflow_service_server::WorkflowServiceServer};
23use tonic::{Request, Response, Status};
24
25use crate::namespace::MintCredentials;
26use crate::{CallerIdentity, ServerState, api::handlers};
27#[cfg(feature = "haematite-backend")]
28use convert::decode_workflow_id;
29use convert::{
30    decode_cancel_request, decode_pause_request, decode_query_request, decode_rename_request,
31    decode_reopen_request, decode_resume_request, decode_signal_request, decode_start_request,
32    encode_cancel_response, encode_pause_response, encode_query_response, encode_rename_response,
33    encode_reopen_response, encode_resume_response, encode_signal_response, encode_start_response,
34};
35#[cfg(feature = "haematite-backend")]
36use routing_resolve::{RouteResolution, StartResolution};
37
38/// Cloneable tonic implementation for workflow management.
39#[derive(Clone)]
40pub struct WorkflowGrpcService {
41    state: ServerState,
42}
43
44impl WorkflowGrpcService {
45    /// Build a tonic workflow service from shared server state.
46    #[must_use]
47    pub const fn new(state: ServerState) -> Self {
48        Self { state }
49    }
50
51    async fn caller<T>(&self, request: &Request<T>) -> Result<CallerIdentity, Status> {
52        caller_from_metadata(request.metadata(), &self.state).await
53    }
54}
55
56/// Construct the generated tonic server wrapper.
57#[must_use]
58pub fn workflow_service(state: ServerState) -> WorkflowServiceServer<WorkflowGrpcService> {
59    WorkflowServiceServer::new(WorkflowGrpcService::new(state))
60}
61
62#[tonic::async_trait]
63impl generated::workflow_service_server::WorkflowService for WorkflowGrpcService {
64    async fn start_workflow(
65        &self,
66        request: Request<generated::StartWorkflowRequest>,
67    ) -> Result<Response<generated::StartWorkflowResponse>, Status> {
68        if self.state.drain_state().is_draining() {
69            return Err(Status::unavailable(
70                "server is draining and not accepting new workflow starts",
71            ));
72        }
73        let caller = self.caller(&request).await?;
74        // R-4 steered start over R-1 remint: a non-empty routing key steers the
75        // start to its shard owner (forwarding when remote); otherwise the R-1
76        // remint places it locally. `placement` is `None` for single-node /
77        // non-clustered boots (and own-all scope), so the engine mints as usual —
78        // default path unchanged. Without the cluster backend there is no
79        // steering or placement at all.
80        #[cfg(feature = "haematite-backend")]
81        {
82            let (metadata, _ext, inner) = request.into_parts();
83            let placement = match self.resolve_start(&inner, &metadata).await {
84                StartResolution::Reject(status) => return Err(status),
85                StartResolution::Reply(reply) => return Ok(Response::new(reply)),
86                StartResolution::Local(placement) => placement,
87            };
88            // Minted-on-use safety net (Phase 1 S6): mint/gate the authorized
89            // namespace before the engine start, sharing the SAME policy the
90            // worker-registration seam applies. Auth-scoped (`guard.scope` runs
91            // inside the handler); placement is the orthogonal steered-start id.
92            // Carry the caller's own credentials onto any forwarded mint, so
93            // the namespace shard's owner authorizes THIS caller rather than an
94            // anonymous peer (the same verbatim-metadata discipline the R-3
95            // request forwarder already applies to a forwarded start).
96            let minter = self
97                .state
98                .namespace_minter()
99                .with_caller_credentials(MintCredentials::from_grpc_metadata(&metadata));
100            let response = handlers::start_with_placement(
101                self.state.namespace_guard(),
102                &caller,
103                decode_start_request(inner),
104                placement,
105                Some(&minter),
106            )
107            .await
108            .map_err(status_from_wire_error)?;
109            return Ok(Response::new(encode_start_response(response)));
110        }
111        #[cfg(not(feature = "haematite-backend"))]
112        {
113            let placement: Option<aion_core::WorkflowId> = None;
114            // Minted-on-use safety net (Phase 1 S6): mint/gate the authorized
115            // namespace before the engine start, sharing the SAME policy the
116            // worker-registration seam applies.
117            let minter = self.state.namespace_minter();
118            let response = handlers::start_with_placement(
119                self.state.namespace_guard(),
120                &caller,
121                decode_start_request(request.into_inner()),
122                placement,
123                Some(&minter),
124            )
125            .await
126            .map_err(status_from_wire_error)?;
127            Ok(Response::new(encode_start_response(response)))
128        }
129    }
130
131    async fn signal(
132        &self,
133        request: Request<generated::SignalRequest>,
134    ) -> Result<Response<generated::SignalResponse>, Status> {
135        let caller = self.caller(&request).await?;
136        #[cfg(feature = "haematite-backend")]
137        {
138            use crate::routing::{ForwardReply, ForwardRequest};
139            let (metadata, _ext, inner) = request.into_parts();
140            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
141            match self
142                .resolve_route(
143                    workflow_id,
144                    &metadata,
145                    ForwardRequest::Signal(inner.clone()),
146                )
147                .await
148            {
149                RouteResolution::Reject(status) => return Err(status),
150                RouteResolution::Reply(ForwardReply::Signal(reply)) => {
151                    return Ok(Response::new(reply));
152                }
153                RouteResolution::Reply(_) => {
154                    return Err(Status::internal("forwarder returned a mismatched reply"));
155                }
156                RouteResolution::Local => {
157                    let response = handlers::signal(
158                        self.state.namespace_guard(),
159                        &caller,
160                        decode_signal_request(inner),
161                    )
162                    .await
163                    .map_err(status_from_wire_error)?;
164                    return Ok(Response::new(encode_signal_response(response)));
165                }
166            }
167        }
168        #[cfg(not(feature = "haematite-backend"))]
169        {
170            let response = handlers::signal(
171                self.state.namespace_guard(),
172                &caller,
173                decode_signal_request(request.into_inner()),
174            )
175            .await
176            .map_err(status_from_wire_error)?;
177            Ok(Response::new(encode_signal_response(response)))
178        }
179    }
180
181    async fn query(
182        &self,
183        request: Request<generated::QueryRequest>,
184    ) -> Result<Response<generated::QueryResponse>, Status> {
185        let caller = self.caller(&request).await?;
186        #[cfg(feature = "haematite-backend")]
187        {
188            use crate::routing::{ForwardReply, ForwardRequest};
189            let (metadata, _ext, inner) = request.into_parts();
190            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
191            match self
192                .resolve_route(workflow_id, &metadata, ForwardRequest::Query(inner.clone()))
193                .await
194            {
195                RouteResolution::Reject(status) => return Err(status),
196                RouteResolution::Reply(ForwardReply::Query(reply)) => {
197                    return Ok(Response::new(reply));
198                }
199                RouteResolution::Reply(_) => {
200                    return Err(Status::internal("forwarder returned a mismatched reply"));
201                }
202                RouteResolution::Local => {
203                    let response = handlers::query(
204                        self.state.namespace_guard(),
205                        &caller,
206                        decode_query_request(inner),
207                    )
208                    .await
209                    .map_err(status_from_wire_error)?;
210                    return Ok(Response::new(encode_query_response(response)));
211                }
212            }
213        }
214        #[cfg(not(feature = "haematite-backend"))]
215        {
216            let response = handlers::query(
217                self.state.namespace_guard(),
218                &caller,
219                decode_query_request(request.into_inner()),
220            )
221            .await
222            .map_err(status_from_wire_error)?;
223            Ok(Response::new(encode_query_response(response)))
224        }
225    }
226
227    async fn cancel(
228        &self,
229        request: Request<generated::CancelRequest>,
230    ) -> Result<Response<generated::CancelResponse>, Status> {
231        let caller = self.caller(&request).await?;
232        #[cfg(feature = "haematite-backend")]
233        {
234            use crate::routing::{ForwardReply, ForwardRequest};
235            let (metadata, _ext, inner) = request.into_parts();
236            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
237            match self
238                .resolve_route(
239                    workflow_id,
240                    &metadata,
241                    ForwardRequest::Cancel(inner.clone()),
242                )
243                .await
244            {
245                RouteResolution::Reject(status) => return Err(status),
246                RouteResolution::Reply(ForwardReply::Cancel(reply)) => {
247                    return Ok(Response::new(reply));
248                }
249                RouteResolution::Reply(_) => {
250                    return Err(Status::internal("forwarder returned a mismatched reply"));
251                }
252                RouteResolution::Local => {
253                    let response = handlers::cancel(
254                        self.state.namespace_guard(),
255                        &caller,
256                        decode_cancel_request(inner),
257                    )
258                    .await
259                    .map_err(status_from_wire_error)?;
260                    return Ok(Response::new(encode_cancel_response(response)));
261                }
262            }
263        }
264        #[cfg(not(feature = "haematite-backend"))]
265        {
266            let response = handlers::cancel(
267                self.state.namespace_guard(),
268                &caller,
269                decode_cancel_request(request.into_inner()),
270            )
271            .await
272            .map_err(status_from_wire_error)?;
273            Ok(Response::new(encode_cancel_response(response)))
274        }
275    }
276
277    async fn reopen(
278        &self,
279        request: Request<generated::ReopenRequest>,
280    ) -> Result<Response<generated::ReopenResponse>, Status> {
281        let caller = self.caller(&request).await?;
282        #[cfg(feature = "haematite-backend")]
283        {
284            use crate::routing::{ForwardReply, ForwardRequest};
285            let (metadata, _ext, inner) = request.into_parts();
286            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
287            match self
288                .resolve_route(
289                    workflow_id,
290                    &metadata,
291                    ForwardRequest::Reopen(inner.clone()),
292                )
293                .await
294            {
295                RouteResolution::Reject(status) => return Err(status),
296                RouteResolution::Reply(ForwardReply::Reopen(reply)) => {
297                    return Ok(Response::new(reply));
298                }
299                RouteResolution::Reply(_) => {
300                    return Err(Status::internal("forwarder returned a mismatched reply"));
301                }
302                RouteResolution::Local => {
303                    let response = handlers::reopen(
304                        self.state.namespace_guard(),
305                        &caller,
306                        decode_reopen_request(inner),
307                    )
308                    .await
309                    .map_err(status_from_wire_error)?;
310                    return Ok(Response::new(encode_reopen_response(response)));
311                }
312            }
313        }
314        #[cfg(not(feature = "haematite-backend"))]
315        {
316            let response = handlers::reopen(
317                self.state.namespace_guard(),
318                &caller,
319                decode_reopen_request(request.into_inner()),
320            )
321            .await
322            .map_err(status_from_wire_error)?;
323            Ok(Response::new(encode_reopen_response(response)))
324        }
325    }
326
327    async fn pause(
328        &self,
329        request: Request<generated::PauseRequest>,
330    ) -> Result<Response<generated::PauseResponse>, Status> {
331        let caller = self.caller(&request).await?;
332        #[cfg(feature = "haematite-backend")]
333        {
334            use crate::routing::{ForwardReply, ForwardRequest};
335            let (metadata, _ext, inner) = request.into_parts();
336            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
337            match self
338                .resolve_route(workflow_id, &metadata, ForwardRequest::Pause(inner.clone()))
339                .await
340            {
341                RouteResolution::Reject(status) => return Err(status),
342                RouteResolution::Reply(ForwardReply::Pause(reply)) => {
343                    return Ok(Response::new(reply));
344                }
345                RouteResolution::Reply(_) => {
346                    return Err(Status::internal("forwarder returned a mismatched reply"));
347                }
348                RouteResolution::Local => {
349                    let response = handlers::pause(
350                        self.state.namespace_guard(),
351                        &caller,
352                        decode_pause_request(inner),
353                    )
354                    .await
355                    .map_err(status_from_wire_error)?;
356                    return Ok(Response::new(encode_pause_response(response)));
357                }
358            }
359        }
360        #[cfg(not(feature = "haematite-backend"))]
361        {
362            let response = handlers::pause(
363                self.state.namespace_guard(),
364                &caller,
365                decode_pause_request(request.into_inner()),
366            )
367            .await
368            .map_err(status_from_wire_error)?;
369            Ok(Response::new(encode_pause_response(response)))
370        }
371    }
372
373    async fn resume(
374        &self,
375        request: Request<generated::ResumeRequest>,
376    ) -> Result<Response<generated::ResumeResponse>, Status> {
377        let caller = self.caller(&request).await?;
378        #[cfg(feature = "haematite-backend")]
379        {
380            use crate::routing::{ForwardReply, ForwardRequest};
381            let (metadata, _ext, inner) = request.into_parts();
382            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
383            match self
384                .resolve_route(
385                    workflow_id,
386                    &metadata,
387                    ForwardRequest::Resume(inner.clone()),
388                )
389                .await
390            {
391                RouteResolution::Reject(status) => return Err(status),
392                RouteResolution::Reply(ForwardReply::Resume(reply)) => {
393                    return Ok(Response::new(reply));
394                }
395                RouteResolution::Reply(_) => {
396                    return Err(Status::internal("forwarder returned a mismatched reply"));
397                }
398                RouteResolution::Local => {
399                    let response = handlers::resume(
400                        self.state.namespace_guard(),
401                        &caller,
402                        decode_resume_request(inner),
403                    )
404                    .await
405                    .map_err(status_from_wire_error)?;
406                    return Ok(Response::new(encode_resume_response(response)));
407                }
408            }
409        }
410        #[cfg(not(feature = "haematite-backend"))]
411        {
412            let response = handlers::resume(
413                self.state.namespace_guard(),
414                &caller,
415                decode_resume_request(request.into_inner()),
416            )
417            .await
418            .map_err(status_from_wire_error)?;
419            Ok(Response::new(encode_resume_response(response)))
420        }
421    }
422
423    async fn rename(
424        &self,
425        request: Request<generated::RenameRequest>,
426    ) -> Result<Response<generated::RenameResponse>, Status> {
427        let caller = self.caller(&request).await?;
428        #[cfg(feature = "haematite-backend")]
429        {
430            use crate::routing::{ForwardReply, ForwardRequest};
431            let (metadata, _ext, inner) = request.into_parts();
432            let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
433            match self
434                .resolve_route(
435                    workflow_id,
436                    &metadata,
437                    ForwardRequest::Rename(inner.clone()),
438                )
439                .await
440            {
441                RouteResolution::Reject(status) => return Err(status),
442                RouteResolution::Reply(ForwardReply::Rename(reply)) => {
443                    return Ok(Response::new(reply));
444                }
445                RouteResolution::Reply(_) => {
446                    return Err(Status::internal("forwarder returned a mismatched reply"));
447                }
448                RouteResolution::Local => {
449                    let response = handlers::rename(
450                        self.state.namespace_guard(),
451                        &caller,
452                        decode_rename_request(inner),
453                    )
454                    .await
455                    .map_err(status_from_wire_error)?;
456                    return Ok(Response::new(encode_rename_response(response)));
457                }
458            }
459        }
460        #[cfg(not(feature = "haematite-backend"))]
461        {
462            let response = handlers::rename(
463                self.state.namespace_guard(),
464                &caller,
465                decode_rename_request(request.into_inner()),
466            )
467            .await
468            .map_err(status_from_wire_error)?;
469            Ok(Response::new(encode_rename_response(response)))
470        }
471    }
472
473    async fn list_workflows(
474        &self,
475        request: Request<generated::ListWorkflowsRequest>,
476    ) -> Result<Response<generated::ListWorkflowsResponse>, Status> {
477        plain_rpcs::list_workflows(&self.state, request).await
478    }
479
480    async fn count_workflows(
481        &self,
482        request: Request<generated::CountWorkflowsRequest>,
483    ) -> Result<Response<generated::CountWorkflowsResponse>, Status> {
484        plain_rpcs::count_workflows(&self.state, request).await
485    }
486
487    async fn describe_workflow(
488        &self,
489        request: Request<generated::DescribeWorkflowRequest>,
490    ) -> Result<Response<generated::DescribeWorkflowResponse>, Status> {
491        plain_rpcs::describe_workflow(&self.state, request).await
492    }
493
494    async fn create_schedule(
495        &self,
496        request: Request<generated::CreateScheduleRequest>,
497    ) -> Result<Response<generated::CreateScheduleResponse>, Status> {
498        plain_rpcs::create_schedule(&self.state, request).await
499    }
500
501    async fn update_schedule(
502        &self,
503        request: Request<generated::UpdateScheduleRequest>,
504    ) -> Result<Response<generated::UpdateScheduleResponse>, Status> {
505        plain_rpcs::update_schedule(&self.state, request).await
506    }
507
508    async fn pause_schedule(
509        &self,
510        request: Request<generated::ScheduleIdRequest>,
511    ) -> Result<Response<generated::PauseScheduleResponse>, Status> {
512        plain_rpcs::pause_schedule(&self.state, request).await
513    }
514
515    async fn resume_schedule(
516        &self,
517        request: Request<generated::ScheduleIdRequest>,
518    ) -> Result<Response<generated::ResumeScheduleResponse>, Status> {
519        plain_rpcs::resume_schedule(&self.state, request).await
520    }
521
522    async fn delete_schedule(
523        &self,
524        request: Request<generated::ScheduleIdRequest>,
525    ) -> Result<Response<generated::DeleteScheduleResponse>, Status> {
526        plain_rpcs::delete_schedule(&self.state, request).await
527    }
528
529    async fn list_schedules(
530        &self,
531        request: Request<generated::ListSchedulesRequest>,
532    ) -> Result<Response<generated::ListSchedulesResponse>, Status> {
533        plain_rpcs::list_schedules(&self.state, request).await
534    }
535
536    async fn describe_schedule(
537        &self,
538        request: Request<generated::ScheduleIdRequest>,
539    ) -> Result<Response<generated::DescribeScheduleResponse>, Status> {
540        plain_rpcs::describe_schedule(&self.state, request).await
541    }
542
543    /// Mint (or gate) an already-authorized namespace set on THIS node — the
544    /// owner side of namespace-mint routing. See
545    /// [`WorkflowGrpcService::mint_namespace_here`] for the authorization and
546    /// policy-parity contract this delegates to.
547    async fn mint_namespace(
548        &self,
549        request: Request<generated::MintNamespaceRequest>,
550    ) -> Result<Response<generated::MintNamespaceResponse>, Status> {
551        self.mint_namespace_here(request).await
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use std::{net::SocketAddr, sync::Arc};
558
559    use aion::EngineBuilder;
560    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
561    use aion_proto::{
562        ProtoWireError, WireError, WireErrorCode,
563        convert::{decode_core_value, encode_core_value},
564        generated::workflow_service_server::WorkflowService,
565    };
566    use aion_store::{
567        EventStore, InMemoryStore, WriteToken,
568        visibility::{VisibilityRecord, VisibilityStore},
569    };
570    use chrono::Utc;
571    use prost::Message;
572    use serde_json::json;
573    use tonic::{Code, Request};
574
575    use super::convert::{decode_envelope, encode_envelope, encode_payload};
576    use super::*;
577    use crate::{
578        NamespaceResolver,
579        config::{
580            AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig,
581            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig,
582            WebSocketConfig, WorkerConfig,
583        },
584    };
585
586    const NAMESPACE: &str = "tenant-a";
587    const TOKEN: &str = "test-token";
588
589    /// Server state whose bearer validation matches the compiled auth path:
590    /// under `feature = "auth"` a real [`crate::auth::JwksCache`] is fetched
591    /// from a live fixture JWKS endpoint; otherwise the development token path
592    /// needs no cache.
593    async fn server_state(
594        resolver: NamespaceResolver,
595        runtime: RuntimeConfig,
596    ) -> Result<ServerState, Box<dyn std::error::Error>> {
597        #[cfg(feature = "auth")]
598        {
599            let url = crate::auth::test_support::serve_jwks()?;
600            let refresh = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
601            let cache = crate::auth::JwksCache::new(url, refresh).await?;
602            Ok(ServerState::from_parts_with_jwks(resolver, runtime, cache))
603        }
604        #[cfg(not(feature = "auth"))]
605        {
606            // Yield to preserve the async signature required by the auth-feature branch.
607            tokio::task::yield_now().await;
608            Ok(ServerState::from_parts(resolver, runtime))
609        }
610    }
611
612    #[tokio::test]
613    async fn in_process_tonic_start_and_list_use_shared_handlers()
614    -> Result<(), Box<dyn std::error::Error>> {
615        let backing = Arc::new(InMemoryStore::default());
616        let store: Arc<dyn EventStore> = backing.clone();
617        let visibility_store: Arc<dyn VisibilityStore> = backing;
618        let engine = Arc::new(
619            EngineBuilder::new()
620                .store_arc(Arc::clone(&store))
621                .visibility_store_arc(Arc::clone(&visibility_store))
622                .scheduler_threads(1)
623                .build()
624                .await?,
625        );
626        store
627            .append(
628                WriteToken::recorder(),
629                &workflow_id(),
630                &[started_event()?],
631                0,
632            )
633            .await?;
634        visibility_store
635            .record_visibility(VisibilityRecord {
636                workflow_id: workflow_id(),
637                run_id: aion_core::RunId::new(uuid::Uuid::from_u128(2)),
638                workflow_type: String::from("fixture"),
639                status: WorkflowStatus::Running,
640                start_time: Utc::now(),
641                close_time: None,
642                failed_step: None,
643                failure_reason: None,
644                search_attributes: std::collections::HashMap::from([(
645                    crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
646                    aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
647                )]),
648            })
649            .await?;
650        let resolver = NamespaceResolver::from_config(
651            crate::config::NamespaceConfig {
652                mode: NamespaceMode::SharedEngine,
653            },
654            engine,
655        );
656        let state = server_state(resolver.clone(), runtime_config()).await?;
657        let service = WorkflowGrpcService::new(state);
658
659        let mut start = Request::new(generated::StartWorkflowRequest {
660            namespace: NAMESPACE.to_owned(),
661            workflow_type: "missing-workflow".to_owned(),
662            input: Some(encode_payload(proto_payload()?)),
663            routing_key: None,
664            task_queue: None,
665            display_name: None,
666        });
667        apply_metadata(start.metadata_mut())?;
668        let start_error = service.start_workflow(start).await;
669        let status = start_error
670            .err()
671            .ok_or_else(|| WireError::backend("expected error"))?;
672        assert_eq!(status.code(), Code::NotFound);
673        let detail = ProtoWireError::decode(status.details())?;
674        assert_eq!(detail.error_type.as_deref(), Some("WorkflowTypeNotFound"));
675        assert!(detail.message.contains("missing-workflow"));
676
677        let list_filter = encode_core_value(
678            NAMESPACE,
679            None,
680            &aion_store::visibility::ListWorkflowsFilter {
681                workflow_type: Some(String::from("fixture")),
682                status: Some(WorkflowStatus::Running),
683                ..aion_store::visibility::ListWorkflowsFilter::default()
684            },
685        )?;
686        let mut list = Request::new(generated::ListWorkflowsRequest {
687            namespace: NAMESPACE.to_owned(),
688            filter: Some(encode_envelope(list_filter)),
689        });
690        apply_metadata(list.metadata_mut())?;
691        let response = service.list_workflows(list).await?.into_inner();
692
693        assert_eq!(response.summaries.len(), 1);
694        let summary = response
695            .summaries
696            .into_iter()
697            .next()
698            .map(decode_envelope)
699            .map(|envelope| decode_core_value::<aion_store::visibility::WorkflowSummary>(&envelope))
700            .transpose()?
701            .ok_or_else(|| WireError::backend("summary missing"))?;
702        assert_eq!(summary.workflow_id, workflow_id());
703        // The seeded history records no namespace attribute, so durable
704        // ownership verification must reject targeted access with NotFound:
705        // a missing ownership attribute is indistinguishable from a
706        // nonexistent workflow (anti-existence-leak), and NamespaceDenied is
707        // reserved for callers without a grant for the requested namespace.
708        assert_eq!(
709            resolver
710                .verify_workflow_ownership(NAMESPACE, &workflow_id())
711                .await
712                .err()
713                .map(|error| error.to_wire_error().code),
714            Some(WireErrorCode::NotFound)
715        );
716        Ok(())
717    }
718
719    /// A gRPC service over a TERMINAL run that records its own namespace.
720    ///
721    /// Terminal is the durable posture in which no live recorder can exist, so
722    /// the engine's non-resident append path is legitimately open (a `Running`
723    /// run with no registered handle is refused instead — see the engine's
724    /// residency gate).
725    ///
726    /// `register_display_name` selects whether the engine's search-attribute
727    /// schema knows `aion.display_name`; the blank-name arm does not need it,
728    /// because the name is refused before any append is attempted.
729    async fn rename_fixture(
730        register_display_name: bool,
731    ) -> Result<(WorkflowGrpcService, Arc<dyn EventStore>), Box<dyn std::error::Error>> {
732        rename_fixture_with_terminal(register_display_name, true).await
733    }
734
735    /// The same fixture, with the run's terminality selectable, so a test can
736    /// also exercise the engine's residency refusal for a durably-`Running`
737    /// run that no registry handle owns.
738    async fn rename_fixture_with_terminal(
739        register_display_name: bool,
740        terminal: bool,
741    ) -> Result<(WorkflowGrpcService, Arc<dyn EventStore>), Box<dyn std::error::Error>> {
742        let backing = Arc::new(InMemoryStore::default());
743        let store: Arc<dyn EventStore> = backing.clone();
744        let visibility_store: Arc<dyn VisibilityStore> = backing;
745        let mut schema = aion_core::SearchAttributeSchema::new();
746        schema.register(
747            crate::namespace::NAMESPACE_ATTRIBUTE,
748            aion_core::SearchAttributeType::String,
749        )?;
750        if register_display_name {
751            schema.register(
752                crate::namespace::DISPLAY_NAME_ATTRIBUTE,
753                aion_core::SearchAttributeType::String,
754            )?;
755        }
756        let engine = Arc::new(
757            EngineBuilder::new()
758                .store_arc(Arc::clone(&store))
759                .visibility_store_arc(Arc::clone(&visibility_store))
760                .search_attribute_schema(schema)
761                .scheduler_threads(1)
762                .build()
763                .await?,
764        );
765        let namespace_event = |seq: u64| Event::SearchAttributesUpdated {
766            envelope: EventEnvelope {
767                seq,
768                recorded_at: Utc::now(),
769                workflow_id: workflow_id(),
770            },
771            workflow_id: workflow_id(),
772            attributes: std::collections::HashMap::from([(
773                crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
774                aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
775            )]),
776        };
777        let mut events = vec![started_event()?, namespace_event(2)];
778        if terminal {
779            events.push(Event::WorkflowCompleted {
780                envelope: EventEnvelope {
781                    seq: 3,
782                    recorded_at: Utc::now(),
783                    workflow_id: workflow_id(),
784                },
785                result: payload()?,
786            });
787        }
788        store
789            .append(WriteToken::recorder(), &workflow_id(), &events, 0)
790            .await?;
791        let resolver = NamespaceResolver::from_config(
792            crate::config::NamespaceConfig {
793                mode: NamespaceMode::SharedEngine,
794            },
795            engine,
796        );
797        let state = server_state(resolver, runtime_config()).await?;
798        Ok((WorkflowGrpcService::new(state), store))
799    }
800
801    /// #211: a rename over the gRPC service records the name durably and
802    /// returns it as recorded (trimmed), and a SECOND rename supersedes it
803    /// while history keeps both — the whole wire path, guard included.
804    #[tokio::test]
805    async fn in_process_tonic_rename_records_the_name_and_supersedes_it()
806    -> Result<(), Box<dyn std::error::Error>> {
807        let (service, store) = rename_fixture(true).await?;
808
809        let rename = |name: &str| {
810            let mut request = Request::new(generated::RenameRequest {
811                namespace: NAMESPACE.to_owned(),
812                workflow_id: Some(generated::WorkflowId {
813                    uuid: workflow_id().to_string(),
814                }),
815                // Absent run id: the rename lands on the LATEST run.
816                run_id: None,
817                display_name: name.to_owned(),
818            });
819            apply_metadata(request.metadata_mut()).map(|()| request)
820        };
821
822        // Surrounding whitespace is trimmed, and the response says so.
823        let response = service.rename(rename("  Nightly settlement  ")?).await?;
824        let response = response.into_inner();
825        assert_eq!(response.display_name, "Nightly settlement");
826        assert_eq!(
827            response.run_id.map(|id| id.uuid),
828            Some(aion_core::RunId::new(uuid::Uuid::from_u128(1)).to_string())
829        );
830
831        service
832            .rename(rename("Nightly settlement (rerun)")?)
833            .await?;
834
835        let history = store.read_history(&workflow_id()).await?;
836        assert_eq!(
837            aion_core::display_name(&history).as_deref(),
838            Some("Nightly settlement (rerun)"),
839            "the latest recorded name wins"
840        );
841        let names: Vec<_> = history
842            .iter()
843            .filter_map(|event| match event {
844                Event::SearchAttributesUpdated { attributes, .. } => attributes
845                    .get(aion_core::DISPLAY_NAME_ATTRIBUTE)
846                    .and_then(|value| match value {
847                        aion_core::SearchAttributeValue::String(name) => Some(name.clone()),
848                        _ => None,
849                    }),
850                _ => None,
851            })
852            .collect();
853        assert_eq!(
854            names,
855            vec![
856                String::from("Nightly settlement"),
857                String::from("Nightly settlement (rerun)")
858            ],
859            "history keeps every name the run has worn"
860        );
861        Ok(())
862    }
863
864    /// #211: a rename carrying a blank name is refused as tonic
865    /// `InvalidArgument` before anything is appended.
866    #[tokio::test]
867    async fn in_process_tonic_rename_refuses_a_blank_name() -> Result<(), Box<dyn std::error::Error>>
868    {
869        let (service, store) = rename_fixture(false).await?;
870        let before = store.read_history(&workflow_id()).await?.len();
871
872        let mut request = Request::new(generated::RenameRequest {
873            namespace: NAMESPACE.to_owned(),
874            workflow_id: Some(generated::WorkflowId {
875                uuid: workflow_id().to_string(),
876            }),
877            run_id: None,
878            display_name: String::from("   "),
879        });
880        apply_metadata(request.metadata_mut())?;
881        let status = service
882            .rename(request)
883            .await
884            .err()
885            .ok_or_else(|| WireError::backend("expected a blank-name refusal"))?;
886        assert_eq!(status.code(), Code::InvalidArgument);
887        assert_eq!(
888            store.read_history(&workflow_id()).await?.len(),
889            before,
890            "a refused rename appends nothing"
891        );
892        Ok(())
893    }
894
895    /// #211 single writer, over the wire: renaming a durably-`Running` run that
896    /// no registry handle owns is refused as tonic `FailedPrecondition`
897    /// carrying the typed `InvalidState` detail — never served through a second
898    /// writer appending behind the run's live recorder. This is the wire
899    /// contract `docs/operations/API.md` states for the route.
900    #[tokio::test]
901    async fn in_process_tonic_rename_of_a_non_resident_running_run_is_failed_precondition()
902    -> Result<(), Box<dyn std::error::Error>> {
903        let (service, store) = rename_fixture_with_terminal(true, false).await?;
904        let before = store.read_history(&workflow_id()).await?.len();
905
906        let mut request = Request::new(generated::RenameRequest {
907            namespace: NAMESPACE.to_owned(),
908            workflow_id: Some(generated::WorkflowId {
909                uuid: workflow_id().to_string(),
910            }),
911            run_id: None,
912            display_name: String::from("Nightly settlement"),
913        });
914        apply_metadata(request.metadata_mut())?;
915        let status = service
916            .rename(request)
917            .await
918            .err()
919            .ok_or_else(|| WireError::backend("expected a residency refusal"))?;
920
921        assert_eq!(status.code(), Code::FailedPrecondition);
922        let detail = ProtoWireError::decode(status.details())?;
923        assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
924        assert!(
925            detail.message.contains("not resident"),
926            "the refusal must name the reason: {}",
927            detail.message
928        );
929        assert_eq!(
930            store.read_history(&workflow_id()).await?.len(),
931            before,
932            "a refused rename appends nothing"
933        );
934        Ok(())
935    }
936
937    /// Reopening a terminal-Completed workflow over the gRPC service surfaces
938    /// the engine's `InvalidState` precondition as tonic `FailedPrecondition`
939    /// carrying the typed `InvalidState` detail (AO-007 C35/C38).
940    #[tokio::test]
941    async fn in_process_tonic_reopen_completed_is_failed_precondition_invalid_state()
942    -> Result<(), Box<dyn std::error::Error>> {
943        let backing = Arc::new(InMemoryStore::default());
944        let store: Arc<dyn EventStore> = backing.clone();
945        let visibility_store: Arc<dyn VisibilityStore> = backing;
946        let engine = Arc::new(
947            EngineBuilder::new()
948                .store_arc(Arc::clone(&store))
949                .visibility_store_arc(Arc::clone(&visibility_store))
950                .scheduler_threads(1)
951                .build()
952                .await?,
953        );
954        // A terminal-Completed run whose history records its namespace, so the
955        // guard's durable-ownership verification passes and the request reaches
956        // the engine reopen op (which rejects Completed with InvalidState).
957        store
958            .append(
959                WriteToken::recorder(),
960                &workflow_id(),
961                &[
962                    started_event()?,
963                    Event::SearchAttributesUpdated {
964                        envelope: EventEnvelope {
965                            seq: 2,
966                            recorded_at: Utc::now(),
967                            workflow_id: workflow_id(),
968                        },
969                        workflow_id: workflow_id(),
970                        attributes: std::collections::HashMap::from([(
971                            crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
972                            aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
973                        )]),
974                    },
975                    Event::WorkflowCompleted {
976                        envelope: EventEnvelope {
977                            seq: 3,
978                            recorded_at: Utc::now(),
979                            workflow_id: workflow_id(),
980                        },
981                        result: payload()?,
982                    },
983                ],
984                0,
985            )
986            .await?;
987        let resolver = NamespaceResolver::from_config(
988            crate::config::NamespaceConfig {
989                mode: NamespaceMode::SharedEngine,
990            },
991            engine,
992        );
993        let state = server_state(resolver, runtime_config()).await?;
994        let service = WorkflowGrpcService::new(state);
995
996        let mut reopen = Request::new(generated::ReopenRequest {
997            namespace: NAMESPACE.to_owned(),
998            workflow_id: Some(generated::WorkflowId {
999                uuid: workflow_id().to_string(),
1000            }),
1001            run_id: None,
1002        });
1003        apply_metadata(reopen.metadata_mut())?;
1004        let status = service
1005            .reopen(reopen)
1006            .await
1007            .err()
1008            .ok_or_else(|| WireError::backend("expected a reopen precondition error"))?;
1009        assert_eq!(status.code(), Code::FailedPrecondition);
1010        let detail = ProtoWireError::decode(status.details())?;
1011        assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
1012        assert_eq!(
1013            detail.code,
1014            aion_proto::ProtoWireErrorCode::InvalidState as i32
1015        );
1016        Ok(())
1017    }
1018
1019    fn apply_metadata(
1020        metadata: &mut tonic::metadata::MetadataMap,
1021    ) -> Result<(), Box<dyn std::error::Error>> {
1022        // Bearer credential accepted by the compiled authentication path: a
1023        // JWT minted against the fixture JWKS under `feature = "auth"`, the
1024        // development shared-secret token otherwise.
1025        #[cfg(feature = "auth")]
1026        let bearer = crate::auth::test_support::mint_token("alice", NAMESPACE)?;
1027        #[cfg(not(feature = "auth"))]
1028        let bearer = TOKEN.to_owned();
1029        metadata.insert("authorization", format!("Bearer {bearer}").parse()?);
1030        metadata.insert("x-aion-subject", "alice".parse()?);
1031        metadata.insert("x-aion-namespaces", NAMESPACE.parse()?);
1032        Ok(())
1033    }
1034
1035    /// Test runtime settings with authentication enabled; under
1036    /// `feature = "auth"` validation runs against the [`server_state`]-injected
1037    /// JWKS cache, so the configured dev-secret `jwks_url` is never fetched.
1038    fn runtime_config() -> RuntimeConfig {
1039        RuntimeConfig {
1040            listen: ListenConfig {
1041                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1042                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1043            },
1044            tls: None,
1045            auth: AuthConfig {
1046                enabled: true,
1047                jwks_url: Some(TOKEN.to_owned()),
1048                jwks_refresh_seconds: 300,
1049            },
1050            ops_console: OpsConsoleConfig {
1051                source: OpsConsoleAssetSource::Embedded,
1052            },
1053            namespace: NamespaceConfig {
1054                mode: NamespaceMode::SharedEngine,
1055            },
1056            worker: WorkerConfig {
1057                heartbeat_window: std::time::Duration::from_secs(30),
1058                ..WorkerConfig::default()
1059            },
1060            websocket: WebSocketConfig {
1061                outbound_buffer_bound: 32,
1062                event_broadcast_capacity: Some(64),
1063                cluster_broadcast_capacity: Some(64),
1064            },
1065            workflow_packages: Vec::new(),
1066            deploy: DeployConfig::default(),
1067            authoring: AuthoringConfig::default(),
1068            dev: crate::config::DevConfig::default(),
1069            outbox: crate::config::OutboxConfig::default(),
1070            observability: crate::config::ObservabilityConfig::default(),
1071            mcp: crate::config::ResolvedMcpConfig::default(),
1072            scheduler_threads: 1,
1073            query_timeout: Some(std::time::Duration::from_secs(10)),
1074            default_namespace: "default".to_owned(),
1075            auto_create: crate::config::AutoCreate::Open,
1076            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1077            drain_timeout: std::time::Duration::from_secs(30),
1078            metrics: MetricsConfig { enabled: true },
1079            owned_shards: Vec::new(),
1080            cors_allowed_origins: Vec::new(),
1081        }
1082    }
1083
1084    fn started_event() -> Result<Event, aion_core::PayloadError> {
1085        Ok(Event::WorkflowStarted {
1086            envelope: EventEnvelope {
1087                seq: 1,
1088                recorded_at: Utc::now(),
1089                workflow_id: workflow_id(),
1090            },
1091            workflow_type: "fixture".to_owned(),
1092            input: payload()?,
1093            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
1094            parent_run_id: None,
1095            package_version: aion_core::PackageVersion::new("a".repeat(64)),
1096        })
1097    }
1098
1099    fn proto_payload() -> Result<aion_proto::ProtoPayload, aion_core::PayloadError> {
1100        Ok(payload()?.into())
1101    }
1102
1103    fn payload() -> Result<Payload, aion_core::PayloadError> {
1104        Payload::from_json(&json!({ "fixture": "input" }))
1105    }
1106
1107    fn workflow_id() -> WorkflowId {
1108        WorkflowId::new(uuid::Uuid::from_u128(1))
1109    }
1110}