Skip to main content

a3s_flow/model/
patch.rs

1use serde::de::{Error as _, SeqAccess, Visitor};
2use serde::{Deserialize, Deserializer, Serialize};
3use std::borrow::Borrow;
4use std::collections::BTreeSet;
5use std::fmt;
6use std::str::FromStr;
7
8use crate::error::{FlowError, Result};
9
10const MAX_WORKFLOW_PATCH_ID_BYTES: usize = 128;
11
12/// Maximum number of replay-safe patch markers pinned to one workflow run.
13pub const MAX_WORKFLOW_PATCH_MARKERS: usize = 256;
14
15/// Stable identity of a replay-safe workflow code change.
16///
17/// Patch IDs are persisted in [`WorkflowSpec`](crate::WorkflowSpec) when a run
18/// is created. They are lowercase, bounded identifiers so they remain safe in
19/// event history, diagnostics, and native runtime payloads.
20#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
21#[serde(transparent)]
22pub struct WorkflowPatchId(String);
23
24impl WorkflowPatchId {
25    /// Validate and create a durable workflow patch identity.
26    pub fn new(value: impl Into<String>) -> Result<Self> {
27        let value = value.into();
28        validate_workflow_patch_id(&value)?;
29        Ok(Self(value))
30    }
31
32    /// Return the validated patch identity text.
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for WorkflowPatchId {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(&self.0)
41    }
42}
43
44impl AsRef<str> for WorkflowPatchId {
45    fn as_ref(&self) -> &str {
46        self.as_str()
47    }
48}
49
50impl Borrow<str> for WorkflowPatchId {
51    fn borrow(&self) -> &str {
52        self.as_str()
53    }
54}
55
56impl FromStr for WorkflowPatchId {
57    type Err = FlowError;
58
59    fn from_str(value: &str) -> Result<Self> {
60        Self::new(value)
61    }
62}
63
64impl<'de> Deserialize<'de> for WorkflowPatchId {
65    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
66    where
67        D: Deserializer<'de>,
68    {
69        let value = String::deserialize(deserializer)?;
70        Self::new(value).map_err(D::Error::custom)
71    }
72}
73
74pub(crate) fn deserialize_patch_markers<'de, D>(
75    deserializer: D,
76) -> std::result::Result<BTreeSet<WorkflowPatchId>, D::Error>
77where
78    D: Deserializer<'de>,
79{
80    struct PatchMarkerSetVisitor;
81
82    impl<'de> Visitor<'de> for PatchMarkerSetVisitor {
83        type Value = BTreeSet<WorkflowPatchId>;
84
85        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86            write!(
87                formatter,
88                "at most {MAX_WORKFLOW_PATCH_MARKERS} unique workflow patch IDs"
89            )
90        }
91
92        fn visit_seq<A>(
93            self,
94            mut sequence: A,
95        ) -> std::result::Result<BTreeSet<WorkflowPatchId>, A::Error>
96        where
97            A: SeqAccess<'de>,
98        {
99            if sequence
100                .size_hint()
101                .is_some_and(|size| size > MAX_WORKFLOW_PATCH_MARKERS)
102            {
103                return Err(A::Error::custom(format!(
104                    "workflow patch marker count exceeds {MAX_WORKFLOW_PATCH_MARKERS}"
105                )));
106            }
107
108            let mut markers = BTreeSet::new();
109            while let Some(marker) = sequence.next_element::<WorkflowPatchId>()? {
110                if markers.len() == MAX_WORKFLOW_PATCH_MARKERS {
111                    return Err(A::Error::custom(format!(
112                        "workflow patch marker count exceeds {MAX_WORKFLOW_PATCH_MARKERS}"
113                    )));
114                }
115                if !markers.insert(marker) {
116                    return Err(A::Error::custom(
117                        "workflow patch markers contain a duplicate ID",
118                    ));
119                }
120            }
121            Ok(markers)
122        }
123    }
124
125    deserializer.deserialize_seq(PatchMarkerSetVisitor)
126}
127
128fn validate_workflow_patch_id(value: &str) -> Result<()> {
129    if value.is_empty() {
130        return Err(FlowError::InvalidWorkflowPatchId(
131            "workflow patch id must not be empty".to_string(),
132        ));
133    }
134    if value.len() > MAX_WORKFLOW_PATCH_ID_BYTES {
135        return Err(FlowError::InvalidWorkflowPatchId(format!(
136            "workflow patch id must not exceed {MAX_WORKFLOW_PATCH_ID_BYTES} bytes"
137        )));
138    }
139    if !value.is_ascii() {
140        return Err(FlowError::InvalidWorkflowPatchId(
141            "workflow patch id must contain only ASCII characters".to_string(),
142        ));
143    }
144    if !value
145        .as_bytes()
146        .first()
147        .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
148        || !value
149            .as_bytes()
150            .last()
151            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
152    {
153        return Err(FlowError::InvalidWorkflowPatchId(
154            "workflow patch id must start and end with a lowercase ASCII letter or digit"
155                .to_string(),
156        ));
157    }
158    if !value.bytes().all(|byte| {
159        byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
160    }) {
161        return Err(FlowError::InvalidWorkflowPatchId(
162            "workflow patch id contains an unsupported character".to_string(),
163        ));
164    }
165    Ok(())
166}