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::test_support::{EngineUnderTest, StateUnderTest};
491    use crate::{
492        NamespaceResolver,
493        config::{
494            AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig,
495            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig,
496            WebSocketConfig, WorkerConfig,
497        },
498    };
499
500    const NAMESPACE: &str = "tenant-a";
501    const TOKEN: &str = "test-token";
502
503    /// Server state whose bearer validation matches the compiled auth path:
504    /// under `feature = "auth"` a real [`crate::auth::JwksCache`] is fetched
505    /// from a live fixture JWKS endpoint; otherwise the development token path
506    /// needs no cache.
507    ///
508    /// The state comes back inside a [`StateUnderTest`] guard, so the engine in
509    /// `resolver` is shut down when the binding ends instead of leaking its
510    /// scheduler threads for the rest of the process.
511    async fn server_state(
512        engine: EngineUnderTest,
513        resolver: NamespaceResolver,
514        runtime: RuntimeConfig,
515    ) -> Result<StateUnderTest, Box<dyn std::error::Error>> {
516        #[cfg(feature = "auth")]
517        {
518            let url = crate::auth::test_support::serve_jwks()?;
519            let refresh = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
520            let cache = crate::auth::JwksCache::new(url, refresh).await?;
521            Ok(StateUnderTest::over(
522                engine,
523                ServerState::from_parts_with_jwks(resolver, runtime, cache),
524            ))
525        }
526        #[cfg(not(feature = "auth"))]
527        {
528            // Yield to preserve the async signature the auth arm requires.
529            tokio::task::yield_now().await;
530            Ok(StateUnderTest::over(
531                engine,
532                ServerState::from_parts(resolver, runtime),
533            ))
534        }
535    }
536
537    /// The list call the in-process test makes: running `fixture` runs in the
538    /// test namespace, newest start first.
539    fn fixture_list_request()
540    -> Result<Request<generated::ListWorkflowsRequest>, Box<dyn std::error::Error>> {
541        let list_request = encode_core_value(
542            NAMESPACE,
543            None,
544            &aion_core::WorkflowListRequest {
545                namespace: NAMESPACE.to_owned(),
546                filter: aion_core::WorkflowListFilter {
547                    workflow_types: vec![String::from("fixture")],
548                    statuses: vec![WorkflowStatus::Running],
549                    ..aion_core::WorkflowListFilter::default()
550                },
551                sort: aion_core::WorkflowSort {
552                    field: aion_core::WorkflowSortField::StartedAt,
553                    direction: aion_core::SortDirection::Desc,
554                },
555                cursor: None,
556                limit: 10,
557            },
558        )?;
559        Ok(Request::new(generated::ListWorkflowsRequest {
560            namespace: NAMESPACE.to_owned(),
561            request: Some(encode_envelope(list_request)),
562        }))
563    }
564
565    /// The start-and-list fixture's two histories and their rows, returning
566    /// the id of the workflow that records no namespace.
567    ///
568    /// ONE ROW PER WORKFLOW, AND THE ROW IS HISTORY'S PROJECTION. A row
569    /// written by hand that disagrees with history (another run, another
570    /// namespace) is exactly the phantom the reconciler replaces at the
571    /// engine's start, so the fixture never writes one: the tenant's
572    /// workflow records its namespace in history and projects its row
573    /// through the same function production uses. Beside it, a workflow
574    /// whose history names NO namespace projects into the default one —
575    /// the list under the tenant must not show it, and targeted access
576    /// to it under the tenant must read `NotFound` (anti-existence-leak).
577    async fn seed_tenant_and_unowned_histories(
578        store: &Arc<dyn EventStore>,
579        visibility_store: &Arc<dyn VisibilityStore>,
580    ) -> Result<WorkflowId, Box<dyn std::error::Error>> {
581        let namespace_event = Event::SearchAttributesUpdated {
582            envelope: EventEnvelope {
583                seq: 2,
584                recorded_at: Utc::now(),
585                workflow_id: workflow_id(),
586            },
587            workflow_id: workflow_id(),
588            attributes: std::collections::HashMap::from([(
589                crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
590                aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
591            )]),
592        };
593        store
594            .append(
595                WriteToken::recorder(),
596                &workflow_id(),
597                &[started_event()?, namespace_event],
598                0,
599            )
600            .await?;
601        let unowned_id = WorkflowId::new(uuid::Uuid::from_u128(3));
602        let unowned_run = aion_core::RunId::new(uuid::Uuid::from_u128(4));
603        store
604            .append(
605                WriteToken::recorder(),
606                &unowned_id,
607                &[Event::WorkflowStarted {
608                    envelope: EventEnvelope {
609                        seq: 1,
610                        recorded_at: Utc::now(),
611                        workflow_id: unowned_id.clone(),
612                    },
613                    workflow_type: "fixture".to_owned(),
614                    input: payload()?,
615                    run_id: unowned_run.clone(),
616                    parent_run_id: None,
617                    parent_workflow_id: None,
618                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
619                }],
620                0,
621            )
622            .await?;
623        for (id, run) in [
624            (
625                workflow_id(),
626                aion_core::RunId::new(uuid::Uuid::from_u128(1)),
627            ),
628            (unowned_id.clone(), unowned_run),
629        ] {
630            aion::lifecycle::visibility::upsert_workflow_visibility(
631                Arc::clone(store),
632                Arc::clone(visibility_store),
633                &id,
634                &run,
635            )
636            .await?;
637        }
638        Ok(unowned_id)
639    }
640
641    #[tokio::test]
642    async fn in_process_tonic_start_and_list_use_shared_handlers()
643    -> Result<(), Box<dyn std::error::Error>> {
644        let backing = Arc::new(InMemoryStore::default());
645        let store: Arc<dyn EventStore> = backing.clone();
646        let visibility_store: Arc<dyn VisibilityStore> = backing;
647        let engine = EngineUnderTest::new(Arc::new(
648            EngineBuilder::new()
649                .stop_drain_timeout(std::time::Duration::from_secs(5))
650                .store_arc(Arc::clone(&store))
651                .visibility_store_arc(Arc::clone(&visibility_store))
652                .scheduler_threads(1)
653                .build()
654                .await?,
655        ));
656        let unowned_id = seed_tenant_and_unowned_histories(&store, &visibility_store).await?;
657        let resolver = NamespaceResolver::from_config(
658            crate::config::NamespaceConfig {
659                mode: NamespaceMode::SharedEngine,
660            },
661            engine.handle(),
662        );
663        let state = server_state(engine, resolver.clone(), runtime_config()).await?;
664        let service = WorkflowGrpcService::new(state.clone());
665
666        let mut start = Request::new(generated::StartWorkflowRequest {
667            namespace: NAMESPACE.to_owned(),
668            workflow_type: "missing-workflow".to_owned(),
669            input: Some(encode_payload(proto_payload()?)),
670            routing_key: None,
671            task_queue: None,
672            display_name: None,
673        });
674        apply_metadata(start.metadata_mut())?;
675        let start_error = service.start_workflow(start).await;
676        let status = start_error
677            .err()
678            .ok_or_else(|| WireError::backend("expected error"))?;
679        assert_eq!(status.code(), Code::NotFound);
680        let detail = ProtoWireError::decode(status.details())?;
681        assert_eq!(detail.error_type.as_deref(), Some("WorkflowTypeNotFound"));
682        assert!(detail.message.contains("missing-workflow"));
683
684        let mut list = fixture_list_request()?;
685        apply_metadata(list.metadata_mut())?;
686        let response = service.list_workflows(list).await?.into_inner();
687
688        let page = response
689            .page
690            .map(decode_envelope)
691            .map(|envelope| decode_core_value::<aion_core::WorkflowListPage>(&envelope))
692            .transpose()?
693            .ok_or_else(|| WireError::backend("page missing"))?;
694        assert_eq!(page.items.len(), 1);
695        assert_eq!(page.count, 1);
696        assert_eq!(page.items[0].workflow_id, workflow_id());
697        // The tenant's own workflow verifies; the one whose history records
698        // no namespace attribute is rejected with NotFound: a missing
699        // ownership attribute is indistinguishable from a nonexistent
700        // workflow (anti-existence-leak), and NamespaceDenied is reserved for
701        // callers without a grant for the requested namespace.
702        resolver
703            .verify_workflow_ownership(NAMESPACE, &workflow_id())
704            .await?;
705        assert_eq!(
706            resolver
707                .verify_workflow_ownership(NAMESPACE, &unowned_id)
708                .await
709                .err()
710                .map(|error| error.to_wire_error().code),
711            Some(WireErrorCode::NotFound)
712        );
713        Ok(())
714    }
715
716    /// A gRPC service over a TERMINAL run that records its own namespace.
717    ///
718    /// Terminal is the durable posture in which no live recorder can exist, so
719    /// the engine's non-resident append path is legitimately open (a `Running`
720    /// run with no registered handle is refused instead — see the engine's
721    /// residency gate).
722    ///
723    /// `register_display_name` selects whether the engine's search-attribute
724    /// schema knows `aion.display_name`; the blank-name arm does not need it,
725    /// because the name is refused before any append is attempted.
726    async fn rename_fixture(
727        register_display_name: bool,
728    ) -> Result<
729        (WorkflowGrpcService, Arc<dyn EventStore>, StateUnderTest),
730        Box<dyn std::error::Error>,
731    > {
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<
742        (WorkflowGrpcService, Arc<dyn EventStore>, StateUnderTest),
743        Box<dyn std::error::Error>,
744    > {
745        let backing = Arc::new(InMemoryStore::default());
746        let store: Arc<dyn EventStore> = backing.clone();
747        let visibility_store: Arc<dyn VisibilityStore> = backing;
748        let mut schema = aion_core::SearchAttributeSchema::new();
749        schema.register(
750            crate::namespace::NAMESPACE_ATTRIBUTE,
751            aion_core::SearchAttributeType::String,
752        )?;
753        if register_display_name {
754            schema.register(
755                crate::namespace::DISPLAY_NAME_ATTRIBUTE,
756                aion_core::SearchAttributeType::String,
757            )?;
758        }
759        let engine = EngineUnderTest::new(Arc::new(
760            EngineBuilder::new()
761                .stop_drain_timeout(std::time::Duration::from_secs(5))
762                .store_arc(Arc::clone(&store))
763                .visibility_store_arc(Arc::clone(&visibility_store))
764                .search_attribute_schema(schema)
765                .scheduler_threads(1)
766                .build()
767                .await?,
768        ));
769        let namespace_event = |seq: u64| Event::SearchAttributesUpdated {
770            envelope: EventEnvelope {
771                seq,
772                recorded_at: Utc::now(),
773                workflow_id: workflow_id(),
774            },
775            workflow_id: workflow_id(),
776            attributes: std::collections::HashMap::from([(
777                crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
778                aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
779            )]),
780        };
781        let mut events = vec![started_event()?, namespace_event(2)];
782        if terminal {
783            events.push(Event::WorkflowCompleted {
784                envelope: EventEnvelope {
785                    seq: 3,
786                    recorded_at: Utc::now(),
787                    workflow_id: workflow_id(),
788                },
789                result: payload()?,
790            });
791        }
792        store
793            .append(WriteToken::recorder(), &workflow_id(), &events, 0)
794            .await?;
795        let resolver = NamespaceResolver::from_config(
796            crate::config::NamespaceConfig {
797                mode: NamespaceMode::SharedEngine,
798            },
799            engine.handle(),
800        );
801        let state = server_state(engine, resolver, runtime_config()).await?;
802        Ok((WorkflowGrpcService::new(state.clone()), store, state))
803    }
804
805    /// #211: a rename over the gRPC service records the name durably and
806    /// returns it as recorded (trimmed), and a SECOND rename supersedes it
807    /// while history keeps both — the whole wire path, guard included.
808    #[tokio::test]
809    async fn in_process_tonic_rename_records_the_name_and_supersedes_it()
810    -> Result<(), Box<dyn std::error::Error>> {
811        let (service, store, state) = rename_fixture(true).await?;
812
813        let rename = |name: &str| {
814            let mut request = Request::new(generated::RenameRequest {
815                namespace: NAMESPACE.to_owned(),
816                workflow_id: Some(generated::WorkflowId {
817                    uuid: workflow_id().to_string(),
818                }),
819                // Absent run id: the rename lands on the LATEST run.
820                run_id: None,
821                display_name: name.to_owned(),
822            });
823            apply_metadata(request.metadata_mut()).map(|()| request)
824        };
825
826        // Surrounding whitespace is trimmed, and the response says so.
827        let response = service.rename(rename("  Nightly settlement  ")?).await?;
828        let response = response.into_inner();
829        assert_eq!(response.display_name, "Nightly settlement");
830        assert_eq!(
831            response.run_id.map(|id| id.uuid),
832            Some(aion_core::RunId::new(uuid::Uuid::from_u128(1)).to_string())
833        );
834
835        service
836            .rename(rename("Nightly settlement (rerun)")?)
837            .await?;
838
839        let history = store.read_history(&workflow_id()).await?;
840        assert_eq!(
841            aion_core::display_name(&history).as_deref(),
842            Some("Nightly settlement (rerun)"),
843            "the latest recorded name wins"
844        );
845        let names: Vec<_> = history
846            .iter()
847            .filter_map(|event| match event {
848                Event::SearchAttributesUpdated { attributes, .. } => attributes
849                    .get(aion_core::DISPLAY_NAME_ATTRIBUTE)
850                    .and_then(|value| match value {
851                        aion_core::SearchAttributeValue::String(name) => Some(name.clone()),
852                        _ => None,
853                    }),
854                _ => None,
855            })
856            .collect();
857        assert_eq!(
858            names,
859            vec![
860                String::from("Nightly settlement"),
861                String::from("Nightly settlement (rerun)")
862            ],
863            "history keeps every name the run has worn"
864        );
865        state.shutdown()?;
866        Ok(())
867    }
868
869    /// #211: a rename carrying a blank name is refused as tonic
870    /// `InvalidArgument` before anything is appended.
871    #[tokio::test]
872    async fn in_process_tonic_rename_refuses_a_blank_name() -> Result<(), Box<dyn std::error::Error>>
873    {
874        let (service, store, state) = rename_fixture(false).await?;
875        let before = store.read_history(&workflow_id()).await?.len();
876
877        let mut request = Request::new(generated::RenameRequest {
878            namespace: NAMESPACE.to_owned(),
879            workflow_id: Some(generated::WorkflowId {
880                uuid: workflow_id().to_string(),
881            }),
882            run_id: None,
883            display_name: String::from("   "),
884        });
885        apply_metadata(request.metadata_mut())?;
886        let status = service
887            .rename(request)
888            .await
889            .err()
890            .ok_or_else(|| WireError::backend("expected a blank-name refusal"))?;
891        assert_eq!(status.code(), Code::InvalidArgument);
892        assert_eq!(
893            store.read_history(&workflow_id()).await?.len(),
894            before,
895            "a refused rename appends nothing"
896        );
897        state.shutdown()?;
898        Ok(())
899    }
900
901    /// #211 single writer, over the wire: renaming a durably-`Running` run that
902    /// no registry handle owns is refused as tonic `FailedPrecondition`
903    /// carrying the typed `InvalidState` detail — never served through a second
904    /// writer appending behind the run's live recorder. This is the wire
905    /// contract `docs/operations/API.md` states for the route.
906    #[tokio::test]
907    async fn in_process_tonic_rename_of_a_non_resident_running_run_is_failed_precondition()
908    -> Result<(), Box<dyn std::error::Error>> {
909        let (service, store, state) = rename_fixture_with_terminal(true, false).await?;
910        let before = store.read_history(&workflow_id()).await?.len();
911
912        let mut request = Request::new(generated::RenameRequest {
913            namespace: NAMESPACE.to_owned(),
914            workflow_id: Some(generated::WorkflowId {
915                uuid: workflow_id().to_string(),
916            }),
917            run_id: None,
918            display_name: String::from("Nightly settlement"),
919        });
920        apply_metadata(request.metadata_mut())?;
921        let status = service
922            .rename(request)
923            .await
924            .err()
925            .ok_or_else(|| WireError::backend("expected a residency refusal"))?;
926
927        assert_eq!(status.code(), Code::FailedPrecondition);
928        let detail = ProtoWireError::decode(status.details())?;
929        assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
930        assert!(
931            detail.message.contains("not resident"),
932            "the refusal must name the reason: {}",
933            detail.message
934        );
935        assert_eq!(
936            store.read_history(&workflow_id()).await?.len(),
937            before,
938            "a refused rename appends nothing"
939        );
940        state.shutdown()?;
941        Ok(())
942    }
943
944    /// Reopening a terminal-Completed workflow over the gRPC service surfaces
945    /// the engine's `InvalidState` precondition as tonic `FailedPrecondition`
946    /// carrying the typed `InvalidState` detail (AO-007 C35/C38).
947    #[tokio::test]
948    async fn in_process_tonic_reopen_completed_is_failed_precondition_invalid_state()
949    -> Result<(), Box<dyn std::error::Error>> {
950        let backing = Arc::new(InMemoryStore::default());
951        let store: Arc<dyn EventStore> = backing.clone();
952        let visibility_store: Arc<dyn VisibilityStore> = backing;
953        let engine = EngineUnderTest::new(Arc::new(
954            EngineBuilder::new()
955                .stop_drain_timeout(std::time::Duration::from_secs(5))
956                .store_arc(Arc::clone(&store))
957                .visibility_store_arc(Arc::clone(&visibility_store))
958                .scheduler_threads(1)
959                .build()
960                .await?,
961        ));
962        // A terminal-Completed run whose history records its namespace, so the
963        // guard's durable-ownership verification passes and the request reaches
964        // the engine reopen op (which rejects Completed with InvalidState).
965        store
966            .append(
967                WriteToken::recorder(),
968                &workflow_id(),
969                &[
970                    started_event()?,
971                    Event::SearchAttributesUpdated {
972                        envelope: EventEnvelope {
973                            seq: 2,
974                            recorded_at: Utc::now(),
975                            workflow_id: workflow_id(),
976                        },
977                        workflow_id: workflow_id(),
978                        attributes: std::collections::HashMap::from([(
979                            crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
980                            aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
981                        )]),
982                    },
983                    Event::WorkflowCompleted {
984                        envelope: EventEnvelope {
985                            seq: 3,
986                            recorded_at: Utc::now(),
987                            workflow_id: workflow_id(),
988                        },
989                        result: payload()?,
990                    },
991                ],
992                0,
993            )
994            .await?;
995        let resolver = NamespaceResolver::from_config(
996            crate::config::NamespaceConfig {
997                mode: NamespaceMode::SharedEngine,
998            },
999            engine.handle(),
1000        );
1001        let state = server_state(engine, resolver, runtime_config()).await?;
1002        let service = WorkflowGrpcService::new(state.clone());
1003
1004        let mut reopen = Request::new(generated::ReopenRequest {
1005            namespace: NAMESPACE.to_owned(),
1006            workflow_id: Some(generated::WorkflowId {
1007                uuid: workflow_id().to_string(),
1008            }),
1009            run_id: None,
1010        });
1011        apply_metadata(reopen.metadata_mut())?;
1012        let status = service
1013            .reopen(reopen)
1014            .await
1015            .err()
1016            .ok_or_else(|| WireError::backend("expected a reopen precondition error"))?;
1017        assert_eq!(status.code(), Code::FailedPrecondition);
1018        let detail = ProtoWireError::decode(status.details())?;
1019        assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
1020        assert_eq!(
1021            detail.code,
1022            aion_proto::ProtoWireErrorCode::InvalidState as i32
1023        );
1024        Ok(())
1025    }
1026
1027    fn apply_metadata(
1028        metadata: &mut tonic::metadata::MetadataMap,
1029    ) -> Result<(), Box<dyn std::error::Error>> {
1030        // Bearer credential accepted by the compiled authentication path: a
1031        // JWT minted against the fixture JWKS under `feature = "auth"`, the
1032        // development shared-secret token otherwise.
1033        #[cfg(feature = "auth")]
1034        let bearer = crate::auth::test_support::mint_token("alice", NAMESPACE)?;
1035        #[cfg(not(feature = "auth"))]
1036        let bearer = TOKEN.to_owned();
1037        metadata.insert("authorization", format!("Bearer {bearer}").parse()?);
1038        metadata.insert("x-aion-subject", "alice".parse()?);
1039        metadata.insert("x-aion-namespaces", NAMESPACE.parse()?);
1040        Ok(())
1041    }
1042
1043    /// Test runtime settings with authentication enabled; under
1044    /// `feature = "auth"` validation runs against the [`server_state`]-injected
1045    /// JWKS cache, so the configured dev-secret `jwks_url` is never fetched.
1046    fn runtime_config() -> RuntimeConfig {
1047        RuntimeConfig {
1048            listen: ListenConfig {
1049                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1050                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1051            },
1052            tls: None,
1053            auth: AuthConfig {
1054                enabled: true,
1055                jwks_url: Some(TOKEN.to_owned()),
1056                jwks_refresh_seconds: 300,
1057            },
1058            ops_console: OpsConsoleConfig {
1059                source: OpsConsoleAssetSource::Embedded,
1060            },
1061            namespace: NamespaceConfig {
1062                mode: NamespaceMode::SharedEngine,
1063            },
1064            worker: WorkerConfig {
1065                heartbeat_window: std::time::Duration::from_secs(30),
1066                ..WorkerConfig::default()
1067            },
1068            websocket: WebSocketConfig {
1069                outbound_buffer_bound: 32,
1070                event_broadcast_capacity: Some(64),
1071                cluster_broadcast_capacity: Some(64),
1072            },
1073            workflow_packages: Vec::new(),
1074            deploy: DeployConfig::default(),
1075            authoring: AuthoringConfig::default(),
1076            dev: crate::config::DevConfig::default(),
1077            outbox: crate::config::OutboxConfig::default(),
1078            observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
1079            mcp: crate::config::ResolvedMcpConfig::default(),
1080            assistant: crate::config::ResolvedAssistantConfig::default(),
1081            scheduler_threads: 1,
1082            stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
1083            jit_threshold: None,
1084            query_timeout: Some(std::time::Duration::from_secs(10)),
1085            workloop_sweep_interval: Some(std::time::Duration::from_millis(50)),
1086            default_namespace: "default".to_owned(),
1087            auto_create: crate::config::AutoCreate::Open,
1088            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1089            drain_timeout: std::time::Duration::from_secs(30),
1090            metrics: MetricsConfig { enabled: true },
1091            owned_shards: Vec::new(),
1092            cors_allowed_origins: Vec::new(),
1093        }
1094    }
1095
1096    fn started_event() -> Result<Event, aion_core::PayloadError> {
1097        Ok(Event::WorkflowStarted {
1098            envelope: EventEnvelope {
1099                seq: 1,
1100                recorded_at: Utc::now(),
1101                workflow_id: workflow_id(),
1102            },
1103            workflow_type: "fixture".to_owned(),
1104            input: payload()?,
1105            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
1106            parent_run_id: None,
1107            parent_workflow_id: None,
1108            package_version: aion_core::PackageVersion::new("a".repeat(64)),
1109        })
1110    }
1111
1112    fn proto_payload() -> Result<aion_proto::ProtoPayload, aion_core::PayloadError> {
1113        Ok(payload()?.into())
1114    }
1115
1116    fn payload() -> Result<Payload, aion_core::PayloadError> {
1117        Payload::from_json(&json!({ "fixture": "input" }))
1118    }
1119
1120    fn workflow_id() -> WorkflowId {
1121        WorkflowId::new(uuid::Uuid::from_u128(1))
1122    }
1123}