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