1use af_context::{InstanceId, WorkflowDraftId};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct WorkflowActivation {
11 pub instance_id: InstanceId,
13 pub lifecycle: crate::LifecyclePolicy,
15 pub config: Value,
17 #[serde(default)]
19 pub bindings: Vec<crate::TriggerBinding>,
20}
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct WorkflowProposal {
25 pub name: String,
27 pub revision: crate::WorkflowRevision,
29 pub profile: crate::ExecutionProfileRevision,
31 pub activation: Option<WorkflowActivation>,
33}
34impl WorkflowProposal {
35 pub fn validate(&self) -> Result<(), String> {
37 if self.name.trim().is_empty() || self.name.chars().count() > 256 {
38 return Err("workflow name requires 1..256 characters".into());
39 }
40 self.revision
41 .validate()
42 .map_err(|error| error.to_string())?;
43 self.profile.validate().map_err(|error| error.to_string())?;
44 if let Some(activation) = &self.activation {
45 activation
46 .lifecycle
47 .validate()
48 .map_err(|error| error.to_string())?;
49 if !activation.config.is_object() {
50 return Err("workflow instance config must be an object".into());
51 }
52 if activation.bindings.len() > 32 {
54 return Err("workflow draft exceeds 32 trigger bindings".into());
55 }
56 let mut ids = std::collections::BTreeSet::new();
57 for binding in &activation.bindings {
58 binding.validate().map_err(|error| error.to_string())?;
59 if binding.instance_id != activation.instance_id || !ids.insert(&binding.id) {
60 return Err(
61 "draft bindings must be unique and target its proposed instance".into(),
62 );
63 }
64 if let Some(branch) = &binding.branch_id {
65 if !self
66 .revision
67 .spec
68 .branches
69 .iter()
70 .any(|candidate| candidate.branch_id == branch.as_str())
71 {
72 return Err("draft binding names an unknown branch".into());
73 }
74 } else if self.revision.spec.branches.len() != 1 {
75 return Err("multi-branch draft bindings require branch_id".into());
76 }
77 }
78 }
79 if serde_json::to_vec(self)
80 .map_err(|error| error.to_string())?
81 .len()
82 > 1_048_576
83 {
84 return Err("workflow draft exceeds 1 MiB".into());
85 }
86 Ok(())
87 }
88}
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum WorkflowDraftStatus {
93 Proposed,
95 Applied,
97 Rejected,
99}
100impl WorkflowDraftStatus {
101 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::Proposed => "proposed",
105 Self::Applied => "applied",
106 Self::Rejected => "rejected",
107 }
108 }
109 pub fn decide(self, accept: bool) -> Result<Self, String> {
111 let target = if accept {
112 Self::Applied
113 } else {
114 Self::Rejected
115 };
116 if self != Self::Proposed && self != target {
117 return Err("workflow draft already decided".into());
118 }
119 Ok(target)
120 }
121}
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct WorkflowDraft {
125 pub draft_id: WorkflowDraftId,
127 pub proposal: WorkflowProposal,
129 pub status: WorkflowDraftStatus,
131 pub created_at: DateTime<Utc>,
133 pub decided_at: Option<DateTime<Utc>>,
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 fn proposal() -> WorkflowProposal {
141 serde_json::from_value(serde_json::json!({
142 "name":"Preview", "revision": {
143 "definition_id":"definition", "revision":1, "content_digest":"revision",
144 "kernel_abi_version":"1", "dependency_set_digest":"deps",
145 "spec":{"spec_id":"spec", "version":"1", "branches":[{
146 "branch_id":"root", "nodes":[{"id":"in", "type":"ingress.event", "config":{}}], "edges":[]
147 }]}
148 },
149 "profile":{"id":"profile", "revision":1,"content_digest":"profile", "mode":"paper", "durability_grade":"standard",
150 "trigger_provider":"events", "data_provider":"reference", "clock_model":"database", "action_provider":"actions"},
151 "activation":{"instance_id":"instance", "lifecycle":crate::LifecyclePolicy::run_once(), "config":{},"bindings":[{
152 "id":"binding","revision":1,"source":"source","event_type":"event","instance_id":"instance",
153 "branch_id":"root","predicate":{},"ordering":"commutative","gap_wait_ms":30000,"gap_limit":10
154 }]}
155 })).unwrap()
156 }
157 #[test]
158 fn proposals_validate_bounds_and_exact_binding_targets() {
159 let valid = proposal();
160 assert!(valid.validate().is_ok());
161 let mut changed = valid.clone();
162 changed.activation = None;
163 assert!(changed.validate().is_ok());
164 for name in [" ".into(), "字".repeat(257)] {
165 changed.name = name;
166 assert!(changed.validate().is_err());
167 }
168 let mut changed = valid.clone();
169 changed.revision.content_digest.clear();
170 assert!(changed.validate().is_err());
171 let mut changed = valid.clone();
172 changed.profile.action_provider.clear();
173 assert!(changed.validate().is_err());
174 let mut changed = valid.clone();
175 changed.activation.as_mut().unwrap().config = Value::Null;
176 assert!(changed.validate().is_err());
177 let mut changed = valid.clone();
178 let binding = &mut changed.activation.as_mut().unwrap().bindings[0];
179 binding.branch_id = None;
180 assert!(changed.validate().is_ok());
181 changed
182 .revision
183 .spec
184 .branches
185 .push(changed.revision.spec.branches[0].clone());
186 changed.revision.spec.branches[1].branch_id = "second".into();
187 assert!(changed.validate().is_err());
188 let mut changed = valid.clone();
189 changed.activation.as_mut().unwrap().bindings[0].branch_id =
190 Some("missing".parse().unwrap());
191 assert!(changed.validate().is_err());
192 let mut changed = valid.clone();
193 changed.activation.as_mut().unwrap().bindings[0].instance_id = "other".parse().unwrap();
194 assert!(changed.validate().is_err());
195 let mut changed = valid.clone();
196 changed.activation.as_mut().unwrap().bindings =
197 vec![valid.activation.as_ref().unwrap().bindings[0].clone(); 2];
198 assert!(changed.validate().is_err());
199 changed.activation.as_mut().unwrap().bindings =
200 vec![valid.activation.as_ref().unwrap().bindings[0].clone(); 33];
201 assert!(changed.validate().is_err());
202 let mut changed = valid.clone();
203 changed.activation.as_mut().unwrap().bindings[0]
204 .source
205 .clear();
206 assert!(changed.validate().is_err());
207 let mut changed = valid.clone();
208 changed.activation.as_mut().unwrap().config =
209 serde_json::json!({"oversized":"x".repeat(1_048_576)});
210 assert!(changed.validate().is_err());
211 let mut untrusted = serde_json::to_value(valid).unwrap();
212 untrusted["subject_id"] = "other".into();
213 assert!(serde_json::from_value::<WorkflowProposal>(untrusted).is_err());
214 }
215 #[test]
216 fn decision_is_irreversible_and_wire_status_is_stable() {
217 use WorkflowDraftStatus::*;
218 for (state, spelling) in [
219 (Proposed, "proposed"),
220 (Applied, "applied"),
221 (Rejected, "rejected"),
222 ] {
223 assert_eq!(state.as_str(), spelling);
224 assert_eq!(serde_json::to_value(state).unwrap(), spelling);
225 }
226 assert_eq!(Proposed.decide(true).unwrap(), Applied);
227 assert_eq!(Proposed.decide(false).unwrap(), Rejected);
228 assert_eq!(Applied.decide(true).unwrap(), Applied);
229 assert_eq!(Rejected.decide(false).unwrap(), Rejected);
230 assert!(Applied.decide(false).is_err());
231 assert!(Rejected.decide(true).is_err());
232 }
233}