Skip to main content

aion_proto/
workflow.rs

1//! Workflow-management serde/prost wire types.
2
3use crate::convert::{ProtoPayload, ProtoRunId, ProtoWorkflowId, WireEnvelope};
4use crate::error::ProtoWireError;
5
6/// Proto representation of `StartWorkflowRequest`.
7#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
8pub struct ProtoStartWorkflowRequest {
9    /// Namespace that scopes the operation.
10    #[prost(string, tag = "1")]
11    pub namespace: String,
12    /// Workflow type name registered with the engine.
13    #[prost(string, tag = "2")]
14    pub workflow_type: String,
15    /// Workflow start input payload.
16    #[prost(message, optional, tag = "3")]
17    pub input: Option<ProtoPayload>,
18    /// R-4 steered-start routing key. When set, the start is steered to
19    /// `shard_for(routing_key)`'s owner (forwarded there when this node is not the
20    /// owner). `None`/empty keeps the unsteered R-1 remint behaviour.
21    #[prost(string, optional, tag = "4")]
22    pub routing_key: Option<String>,
23    /// Optional task queue this workflow defaults its activities to (the
24    /// namespace × `task_queue` targeting story). When set, the server records it
25    /// durably on the start so it survives replay/failover. `None`/empty keeps
26    /// the namespace's default queue.
27    #[prost(string, optional, tag = "5")]
28    pub task_queue: Option<String>,
29    /// Optional operator-facing display name for the workflow this start
30    /// creates (#211). A LABEL over the UUID identity, never an address:
31    /// nothing resolves a workflow by name. When set, the server records it
32    /// durably (as the `aion.display_name` search attribute) in the same atomic
33    /// append as the start. `None` starts it unnamed; a present but blank
34    /// (empty or whitespace-only) value is REFUSED with `invalid_input` rather
35    /// than reinterpreted as unnamed, so pass `None` to mean "no name" — never
36    /// `Some("")`. Unlike `routing_key` and `task_queue`, empty here is not
37    /// "not selected".
38    ///
39    /// The recorded attribute carries no run id, so it reads back per WORKFLOW
40    /// (folded over the whole history, last write wins) rather than per run.
41    #[prost(string, optional, tag = "6")]
42    pub display_name: Option<String>,
43}
44
45/// Proto representation of `StartWorkflowResponse`.
46#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
47pub struct ProtoStartWorkflowResponse {
48    /// Assigned workflow identifier.
49    #[prost(message, optional, tag = "1")]
50    pub workflow_id: Option<ProtoWorkflowId>,
51    /// Assigned concrete run identifier.
52    #[prost(message, optional, tag = "2")]
53    pub run_id: Option<ProtoRunId>,
54}
55
56/// Proto representation of `SignalRequest`.
57#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
58pub struct ProtoSignalRequest {
59    /// Namespace that scopes the operation.
60    #[prost(string, tag = "1")]
61    pub namespace: String,
62    /// Target workflow identifier.
63    #[prost(message, optional, tag = "2")]
64    pub workflow_id: Option<ProtoWorkflowId>,
65    /// Target run identifier.
66    #[prost(message, optional, tag = "3")]
67    pub run_id: Option<ProtoRunId>,
68    /// Signal name registered by workflow code.
69    #[prost(string, tag = "4")]
70    pub signal_name: String,
71    /// Signal payload.
72    #[prost(message, optional, tag = "5")]
73    pub payload: Option<ProtoPayload>,
74}
75
76/// Proto representation of `SignalResponse`.
77#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
78pub struct ProtoSignalResponse {}
79
80/// Proto representation of `QueryRequest`.
81#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
82pub struct ProtoQueryRequest {
83    /// Namespace that scopes the operation.
84    #[prost(string, tag = "1")]
85    pub namespace: String,
86    /// Target workflow identifier.
87    #[prost(message, optional, tag = "2")]
88    pub workflow_id: Option<ProtoWorkflowId>,
89    /// Target run identifier.
90    #[prost(message, optional, tag = "3")]
91    pub run_id: Option<ProtoRunId>,
92    /// Query name registered by workflow code.
93    #[prost(string, tag = "4")]
94    pub query_name: String,
95    /// Caller-supplied arguments handed to the workflow's query handler.
96    ///
97    /// Absent means "no arguments": the server materializes the JSON `null`
98    /// document so a handler always receives one well-formed input. Arguments
99    /// are read-only handler inputs and are never recorded in history.
100    #[prost(message, optional, tag = "5")]
101    pub arguments: Option<ProtoPayload>,
102}
103
104/// Proto representation of `QueryResponse`.
105#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
106pub struct ProtoQueryResponse {
107    /// Query result or typed wire error.
108    #[prost(oneof = "proto_query_response::Outcome", tags = "1, 2")]
109    pub outcome: Option<proto_query_response::Outcome>,
110}
111
112/// Types nested under [`ProtoQueryResponse`].
113pub mod proto_query_response {
114    /// Proto oneof for successful query payloads and typed failures.
115    #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Oneof)]
116    pub enum Outcome {
117        /// Query result payload.
118        #[prost(message, tag = "1")]
119        Result(super::ProtoPayload),
120        /// Typed query error.
121        #[prost(message, tag = "2")]
122        Error(super::ProtoWireError),
123    }
124}
125
126/// Proto representation of `CancelRequest`.
127#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
128pub struct ProtoCancelRequest {
129    /// Namespace that scopes the operation.
130    #[prost(string, tag = "1")]
131    pub namespace: String,
132    /// Target workflow identifier.
133    #[prost(message, optional, tag = "2")]
134    pub workflow_id: Option<ProtoWorkflowId>,
135    /// Target run identifier.
136    #[prost(message, optional, tag = "3")]
137    pub run_id: Option<ProtoRunId>,
138    /// Human-readable cancellation reason.
139    #[prost(string, tag = "4")]
140    pub reason: String,
141}
142
143/// Proto representation of `CancelResponse`.
144#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
145pub struct ProtoCancelResponse {}
146
147/// Proto representation of `RetireWorkloopRequest`.
148///
149/// Mirrors [`ProtoCancelRequest`] without a `run_id` and without any body
150/// selector. No run id, because retirement targets the LOOP and always acts on
151/// its current generation — a loop's earlier generations are already
152/// terminated by their own iteration closes. No body selector, because whether
153/// the declared `retire` body runs is decided by the deployed document, not by
154/// the caller: an argument that could skip a declared cleanup is how a lease is
155/// lost.
156#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
157pub struct ProtoRetireWorkloopRequest {
158    /// Namespace that scopes the operation.
159    #[prost(string, tag = "1")]
160    pub namespace: String,
161    /// Target workloop identifier.
162    #[prost(message, optional, tag = "2")]
163    pub workflow_id: Option<ProtoWorkflowId>,
164    /// The operator's stated reason, recorded verbatim on `LoopRetired`.
165    #[prost(string, tag = "3")]
166    pub reason: String,
167}
168
169/// Proto representation of `RetireWorkloopResponse`: the reason the
170/// retirement recorded, so a caller that supplied none learns what the loop's
171/// history now says.
172#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
173pub struct ProtoRetireWorkloopResponse {
174    /// The reason recorded on `LoopRetired`.
175    #[prost(string, tag = "1")]
176    pub reason: String,
177}
178
179/// Proto representation of `ReopenRequest`.
180///
181/// Mirrors [`ProtoCancelRequest`] without a `reason`: the reopen carries only a
182/// target. An absent `run_id` means the latest run.
183#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
184pub struct ProtoReopenRequest {
185    /// Namespace that scopes the operation.
186    #[prost(string, tag = "1")]
187    pub namespace: String,
188    /// Target workflow identifier.
189    #[prost(message, optional, tag = "2")]
190    pub workflow_id: Option<ProtoWorkflowId>,
191    /// Target run identifier (absent means the latest run).
192    #[prost(message, optional, tag = "3")]
193    pub run_id: Option<ProtoRunId>,
194}
195
196/// Proto representation of `ReopenResponse`.
197///
198/// Unlike [`ProtoCancelResponse`] (an empty ack) this returns the reopened run
199/// id and its projected status (Running) so the caller learns the run is live
200/// again.
201#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
202pub struct ProtoReopenResponse {
203    /// The reopened concrete run identifier.
204    #[prost(message, optional, tag = "1")]
205    pub run_id: Option<ProtoRunId>,
206    /// The projected workflow status after the reopen (Running).
207    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
208    pub status: i32,
209}
210
211/// Proto representation of `PauseRequest` (#204).
212///
213/// Mirrors [`ProtoCancelRequest`]: a target plus an optional reason. An absent
214/// `run_id` means the latest run.
215#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
216pub struct ProtoPauseRequest {
217    /// Namespace that scopes the operation.
218    #[prost(string, tag = "1")]
219    pub namespace: String,
220    /// Target workflow identifier.
221    #[prost(message, optional, tag = "2")]
222    pub workflow_id: Option<ProtoWorkflowId>,
223    /// Target run identifier (absent means the latest run).
224    #[prost(message, optional, tag = "3")]
225    pub run_id: Option<ProtoRunId>,
226    /// Optional operator-supplied pause reason.
227    #[prost(string, tag = "4")]
228    pub reason: String,
229}
230
231/// Proto representation of `PauseResponse` (#204): the paused run and its status.
232#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
233pub struct ProtoPauseResponse {
234    /// The paused concrete run identifier.
235    #[prost(message, optional, tag = "1")]
236    pub run_id: Option<ProtoRunId>,
237    /// The projected workflow status after the pause (Paused).
238    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
239    pub status: i32,
240}
241
242/// Proto representation of `ResumeRequest` (#204).
243///
244/// Mirrors [`ProtoReopenRequest`]: only a target. An absent `run_id` means the
245/// latest run.
246#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
247pub struct ProtoResumeRequest {
248    /// Namespace that scopes the operation.
249    #[prost(string, tag = "1")]
250    pub namespace: String,
251    /// Target workflow identifier.
252    #[prost(message, optional, tag = "2")]
253    pub workflow_id: Option<ProtoWorkflowId>,
254    /// Target run identifier (absent means the latest run).
255    #[prost(message, optional, tag = "3")]
256    pub run_id: Option<ProtoRunId>,
257}
258
259/// Proto representation of `ResumeResponse` (#204): the resumed run and status.
260#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
261pub struct ProtoResumeResponse {
262    /// The resumed concrete run identifier.
263    #[prost(message, optional, tag = "1")]
264    pub run_id: Option<ProtoRunId>,
265    /// The projected workflow status after the resume (Running).
266    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
267    pub status: i32,
268}
269
270/// Proto representation of `RenameRequest` (#211).
271///
272/// Mirrors [`ProtoPauseRequest`]'s shape: a target plus the operator's payload
273/// (here the new display name). An absent `run_id` means the latest run. The
274/// name is a LABEL over the UUID identity, never an address — this request
275/// SETS a name on an id-addressed run; nothing resolves a workflow by name.
276///
277/// Both facts hold at once, and they are easy to confuse: you ADDRESS a run
278/// (workflow id plus run id, so the server can refuse a rename it cannot append
279/// safely), but what gets recorded is a WORKFLOW-level attribute with no run id
280/// in it. The name therefore reads back for every run of that workflow, which
281/// is exactly why the engine refuses to rename a continue-as-new predecessor:
282/// its name would land on the successor that now owns the history head.
283#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
284pub struct ProtoRenameRequest {
285    /// Namespace that scopes the operation.
286    #[prost(string, tag = "1")]
287    pub namespace: String,
288    /// Target workflow identifier.
289    #[prost(message, optional, tag = "2")]
290    pub workflow_id: Option<ProtoWorkflowId>,
291    /// Target run identifier (absent means the latest run).
292    #[prost(message, optional, tag = "3")]
293    pub run_id: Option<ProtoRunId>,
294    /// The new display name. Trimmed by the server; must be non-empty after
295    /// trimming.
296    #[prost(string, tag = "4")]
297    pub display_name: String,
298}
299
300/// Proto representation of `RenameResponse` (#211): the renamed run and the
301/// display name as recorded (trimmed).
302#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
303pub struct ProtoRenameResponse {
304    /// The renamed concrete run identifier.
305    #[prost(message, optional, tag = "1")]
306    pub run_id: Option<ProtoRunId>,
307    /// The display name as recorded (trimmed).
308    #[prost(string, tag = "2")]
309    pub display_name: String,
310}
311
312/// Proto representation of `ListWorkflowsRequest`.
313#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
314pub struct ProtoListWorkflowsRequest {
315    /// Namespace that scopes the operation.
316    #[prost(string, tag = "1")]
317    pub namespace: String,
318    /// Serde-encoded `aion_store::visibility::ListWorkflowsFilter` envelope.
319    #[prost(message, optional, tag = "2")]
320    pub filter: Option<WireEnvelope>,
321}
322
323/// Proto representation of `ListWorkflowsResponse`.
324#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
325pub struct ProtoListWorkflowsResponse {
326    /// Serde-encoded `aion_store::visibility::WorkflowSummary` envelopes.
327    #[prost(message, repeated, tag = "1")]
328    pub summaries: Vec<WireEnvelope>,
329}
330
331/// Proto representation of `CountWorkflowsRequest`.
332#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
333pub struct ProtoCountWorkflowsRequest {
334    /// Namespace that scopes the operation.
335    #[prost(string, tag = "1")]
336    pub namespace: String,
337    /// Serde-encoded `aion_store::visibility::ListWorkflowsFilter` envelope.
338    #[prost(message, optional, tag = "2")]
339    pub filter: Option<WireEnvelope>,
340}
341
342/// Proto representation of `CountWorkflowsResponse`.
343#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
344pub struct ProtoCountWorkflowsResponse {
345    /// Number of visibility summaries matching the filter.
346    #[prost(uint64, tag = "1")]
347    pub count: u64,
348}
349
350/// Proto representation of `DescribeWorkflowRequest`.
351#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
352pub struct ProtoDescribeWorkflowRequest {
353    /// Namespace that scopes the operation.
354    #[prost(string, tag = "1")]
355    pub namespace: String,
356    /// Target workflow identifier.
357    #[prost(message, optional, tag = "2")]
358    pub workflow_id: Option<ProtoWorkflowId>,
359    /// Target run identifier.
360    #[prost(message, optional, tag = "3")]
361    pub run_id: Option<ProtoRunId>,
362    /// Whether event history should be included in the response.
363    #[prost(bool, tag = "4")]
364    pub include_history: bool,
365}
366
367#[cfg(test)]
368mod tests {
369    use std::collections::HashMap;
370
371    use aion_core::SearchAttributeValue;
372    use aion_store::visibility::{ListWorkflowsFilter, SearchAttributePredicate};
373    use chrono::{DateTime, Utc};
374    use prost::Message;
375    use serde::de::DeserializeOwned;
376    use serde_json::json;
377
378    use super::{
379        ProtoCountWorkflowsRequest, ProtoCountWorkflowsResponse, ProtoListWorkflowsRequest,
380        ProtoListWorkflowsResponse, ProtoQueryRequest, ProtoQueryResponse, ProtoReopenRequest,
381        ProtoReopenResponse, ProtoStartWorkflowRequest, ProtoStartWorkflowResponse,
382        proto_query_response,
383    };
384    use crate::convert::{
385        ProtoPayload, ProtoRunId, ProtoWorkflowId, decode_core_value, encode_core_value,
386    };
387    use crate::error::{ProtoWireError, WireError};
388
389    fn workflow_id() -> aion_core::WorkflowId {
390        aion_core::WorkflowId::new(uuid::Uuid::nil())
391    }
392
393    fn run_id() -> aion_core::RunId {
394        aion_core::RunId::new(uuid::Uuid::nil())
395    }
396
397    fn payload(label: &str) -> Result<ProtoPayload, aion_core::PayloadError> {
398        Ok(ProtoPayload::from(aion_core::Payload::from_json(
399            &json!({ "label": label }),
400        )?))
401    }
402
403    fn recorded_at() -> Result<DateTime<Utc>, chrono::ParseError> {
404        Ok(DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")?.with_timezone(&Utc))
405    }
406
407    fn assert_json_round_trip<T>(value: &T) -> Result<(), serde_json::Error>
408    where
409        T: Clone + PartialEq + serde::Serialize + DeserializeOwned,
410    {
411        let encoded = serde_json::to_string(value)?;
412        let decoded = serde_json::from_str::<T>(&encoded)?;
413        assert!(decoded == *value);
414        Ok(())
415    }
416
417    fn assert_proto_round_trip<T>(value: &T) -> Result<(), Box<dyn std::error::Error>>
418    where
419        T: Clone + PartialEq + Message + Default,
420    {
421        let mut bytes = Vec::new();
422        value.encode(&mut bytes)?;
423        let decoded = T::decode(bytes.as_slice())?;
424        assert!(decoded == *value);
425        Ok(())
426    }
427
428    #[test]
429    fn start_workflow_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
430        let request = ProtoStartWorkflowRequest {
431            namespace: String::from("tenant-a"),
432            workflow_type: String::from("checkout"),
433            input: Some(payload("input")?),
434            routing_key: Some(String::from("tenant-a/order-1")),
435            task_queue: Some(String::from("gpu")),
436            display_name: Some(String::from("Order 1 checkout")),
437        };
438        let response = ProtoStartWorkflowResponse {
439            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
440            run_id: Some(ProtoRunId::from(run_id())),
441        };
442
443        assert_json_round_trip(&request)?;
444        assert_proto_round_trip(&request)?;
445        assert_json_round_trip(&response)?;
446        assert_proto_round_trip(&response)?;
447        Ok(())
448    }
449
450    #[test]
451    fn list_workflows_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
452        let filter = ListWorkflowsFilter {
453            workflow_type: Some(String::from("checkout")),
454            status: Some(aion_core::WorkflowStatus::Running),
455            search_attributes: vec![SearchAttributePredicate::Equals {
456                name: String::from("customer_id"),
457                value: SearchAttributeValue::String(String::from("12345")),
458            }],
459            limit: Some(10),
460            offset: Some(5),
461            ..ListWorkflowsFilter::default()
462        };
463        let summary = aion_store::visibility::WorkflowSummary {
464            workflow_id: workflow_id(),
465            run_id: run_id(),
466            workflow_type: String::from("checkout"),
467            status: aion_core::WorkflowStatus::Running,
468            start_time: recorded_at()?,
469            close_time: None,
470            failed_step: None,
471            failure_reason: None,
472            search_attributes: HashMap::from([(
473                String::from("customer_id"),
474                SearchAttributeValue::String(String::from("12345")),
475            )]),
476        };
477        let filter_envelope = encode_core_value("tenant-a", Some(String::from("r1")), &filter)?;
478        let summary_envelope = encode_core_value("tenant-a", None, &summary)?;
479        let request = ProtoListWorkflowsRequest {
480            namespace: String::from("tenant-a"),
481            filter: Some(filter_envelope.clone()),
482        };
483        let response = ProtoListWorkflowsResponse {
484            summaries: vec![summary_envelope.clone()],
485        };
486        let count_request = ProtoCountWorkflowsRequest {
487            namespace: String::from("tenant-a"),
488            filter: Some(filter_envelope.clone()),
489        };
490        let count_response = ProtoCountWorkflowsResponse { count: 1 };
491
492        assert_json_round_trip(&request)?;
493        assert_proto_round_trip(&request)?;
494        assert_json_round_trip(&response)?;
495        assert_proto_round_trip(&response)?;
496        assert_json_round_trip(&count_request)?;
497        assert_proto_round_trip(&count_request)?;
498        assert_json_round_trip(&count_response)?;
499        assert_proto_round_trip(&count_response)?;
500        assert_eq!(
501            decode_core_value::<ListWorkflowsFilter>(&filter_envelope)?,
502            filter
503        );
504        assert_eq!(
505            decode_core_value::<aion_store::visibility::WorkflowSummary>(&summary_envelope)?,
506            summary
507        );
508        Ok(())
509    }
510
511    #[test]
512    fn query_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
513        let request = ProtoQueryRequest {
514            namespace: String::from("tenant-a"),
515            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
516            run_id: Some(ProtoRunId::from(run_id())),
517            query_name: String::from("state"),
518            arguments: Some(payload("arguments")?),
519        };
520        // A caller that supplies no arguments is a distinct encoded shape, not
521        // an error: the field is optional on the wire and the server supplies
522        // the canonical `null` document in its place.
523        let no_arguments_request = ProtoQueryRequest {
524            arguments: None,
525            ..request.clone()
526        };
527        let result_response = ProtoQueryResponse {
528            outcome: Some(proto_query_response::Outcome::Result(payload("result")?)),
529        };
530        let error_response = ProtoQueryResponse {
531            outcome: Some(proto_query_response::Outcome::Error(ProtoWireError::from(
532                WireError::unknown_query("state query is not registered"),
533            ))),
534        };
535
536        assert_json_round_trip(&request)?;
537        assert_proto_round_trip(&request)?;
538        assert_json_round_trip(&no_arguments_request)?;
539        assert_proto_round_trip(&no_arguments_request)?;
540        assert_json_round_trip(&result_response)?;
541        assert_proto_round_trip(&result_response)?;
542        assert_json_round_trip(&error_response)?;
543        assert_proto_round_trip(&error_response)?;
544        // The two shapes stay distinguishable across a proto round trip: a
545        // decoder can tell "no arguments supplied" from any supplied document.
546        assert_ne!(request, no_arguments_request);
547        Ok(())
548    }
549
550    /// #211: an unnamed start is a distinct wire shape from a named one, and
551    /// both round-trip across JSON and proto without conflating.
552    #[test]
553    fn start_workflow_display_name_absent_round_trips() -> Result<(), Box<dyn std::error::Error>> {
554        let named = ProtoStartWorkflowRequest {
555            namespace: String::from("tenant-a"),
556            workflow_type: String::from("checkout"),
557            input: Some(payload("input")?),
558            routing_key: None,
559            task_queue: None,
560            display_name: Some(String::from("Order 1 checkout")),
561        };
562        let unnamed = ProtoStartWorkflowRequest {
563            display_name: None,
564            ..named.clone()
565        };
566
567        assert_json_round_trip(&named)?;
568        assert_proto_round_trip(&named)?;
569        assert_json_round_trip(&unnamed)?;
570        assert_proto_round_trip(&unnamed)?;
571        // The two shapes stay distinguishable across the wire: a decoder can
572        // tell "no name supplied" from any supplied name.
573        assert_ne!(named, unnamed);
574        Ok(())
575    }
576
577    /// #211: the HAND-WRITTEN `display_name` and the GENERATED stub's
578    /// `display_name` are the same wire field.
579    ///
580    /// Both sides declare tag 6, but "both say 6" is two claims, not agreement.
581    /// This encodes with one and decodes with the other, in both directions, so
582    /// a tag or wire-type drift between the `.proto` and the hand-written prost
583    /// derive is a failure here rather than a field that silently vanishes at a
584    /// real transport boundary. Compiled only under `generated`, which is the
585    /// only posture in which the stubs exist at all.
586    #[cfg(feature = "generated")]
587    #[test]
588    fn start_workflow_display_name_is_the_same_wire_field_as_the_generated_stub()
589    -> Result<(), Box<dyn std::error::Error>> {
590        const NAME: &str = "Nightly settlement";
591        let hand_written = ProtoStartWorkflowRequest {
592            namespace: String::from("tenant-a"),
593            workflow_type: String::from("checkout"),
594            input: None,
595            routing_key: None,
596            task_queue: None,
597            display_name: Some(String::from(NAME)),
598        };
599
600        // Hand-written -> generated.
601        let mut bytes = Vec::new();
602        hand_written.encode(&mut bytes)?;
603        let decoded = crate::generated::StartWorkflowRequest::decode(bytes.as_slice())?;
604        assert_eq!(
605            decoded.display_name.as_deref(),
606            Some(NAME),
607            "the generated stub must read the hand-written display_name"
608        );
609
610        // Generated -> hand-written.
611        let mut bytes = Vec::new();
612        decoded.encode(&mut bytes)?;
613        let round_tripped = ProtoStartWorkflowRequest::decode(bytes.as_slice())?;
614        assert_eq!(round_tripped, hand_written);
615
616        // And the field key itself is pinned: tag 6, length-delimited, is
617        // (6 << 3) | 2 = 0x32. An unnamed start emits it nowhere.
618        let mut bytes = Vec::new();
619        ProtoStartWorkflowRequest {
620            namespace: String::new(),
621            workflow_type: String::new(),
622            input: None,
623            routing_key: None,
624            task_queue: None,
625            display_name: Some(String::from("x")),
626        }
627        .encode(&mut bytes)?;
628        assert_eq!(bytes, vec![0x32, 0x01, b'x']);
629
630        let mut bytes = Vec::new();
631        ProtoStartWorkflowRequest {
632            namespace: String::new(),
633            workflow_type: String::new(),
634            input: None,
635            routing_key: None,
636            task_queue: None,
637            display_name: None,
638        }
639        .encode(&mut bytes)?;
640        assert!(
641            bytes.is_empty(),
642            "an unnamed start must put nothing on the wire, got {bytes:?}"
643        );
644        Ok(())
645    }
646
647    /// #211: the hand-written Rename messages agree with the generated stubs,
648    /// in both directions.
649    #[cfg(feature = "generated")]
650    #[test]
651    fn rename_messages_agree_with_the_generated_stubs() -> Result<(), Box<dyn std::error::Error>> {
652        let request = super::ProtoRenameRequest {
653            namespace: String::from("tenant-a"),
654            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
655            run_id: Some(ProtoRunId::from(run_id())),
656            display_name: String::from("Nightly settlement"),
657        };
658        let mut bytes = Vec::new();
659        request.encode(&mut bytes)?;
660        let decoded = crate::generated::RenameRequest::decode(bytes.as_slice())?;
661        assert_eq!(decoded.display_name, "Nightly settlement");
662        assert_eq!(decoded.namespace, "tenant-a");
663        let mut bytes = Vec::new();
664        decoded.encode(&mut bytes)?;
665        assert_eq!(
666            super::ProtoRenameRequest::decode(bytes.as_slice())?,
667            request
668        );
669
670        let response = super::ProtoRenameResponse {
671            run_id: Some(ProtoRunId::from(run_id())),
672            display_name: String::from("Nightly settlement"),
673        };
674        let mut bytes = Vec::new();
675        response.encode(&mut bytes)?;
676        let decoded = crate::generated::RenameResponse::decode(bytes.as_slice())?;
677        assert_eq!(decoded.display_name, "Nightly settlement");
678        let mut bytes = Vec::new();
679        decoded.encode(&mut bytes)?;
680        assert_eq!(
681            super::ProtoRenameResponse::decode(bytes.as_slice())?,
682            response
683        );
684        Ok(())
685    }
686
687    #[test]
688    fn rename_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
689        let request = super::ProtoRenameRequest {
690            namespace: String::from("tenant-a"),
691            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
692            run_id: Some(ProtoRunId::from(run_id())),
693            display_name: String::from("Nightly settlement"),
694        };
695        let response = super::ProtoRenameResponse {
696            run_id: Some(ProtoRunId::from(run_id())),
697            display_name: String::from("Nightly settlement"),
698        };
699
700        assert_json_round_trip(&request)?;
701        assert_proto_round_trip(&request)?;
702        assert_json_round_trip(&response)?;
703        assert_proto_round_trip(&response)?;
704        Ok(())
705    }
706
707    #[test]
708    fn reopen_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
709        let request = ProtoReopenRequest {
710            namespace: String::from("tenant-a"),
711            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
712            run_id: Some(ProtoRunId::from(run_id())),
713        };
714        let response = ProtoReopenResponse {
715            run_id: Some(ProtoRunId::from(run_id())),
716            status: crate::convert::ProtoWorkflowStatus::Running as i32,
717        };
718
719        assert_json_round_trip(&request)?;
720        assert_proto_round_trip(&request)?;
721        assert_json_round_trip(&response)?;
722        assert_proto_round_trip(&response)?;
723        Ok(())
724    }
725}