Skip to main content

connector_client/workflow/
spec.rs

1use super::{ValueExpr, WorkflowError};
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase", deny_unknown_fields)]
8pub struct WorkflowSpec {
9    pub schema_version: u32,
10    pub run_key: String,
11    #[serde(default = "strict")]
12    pub mode: String,
13    #[serde(default = "sequential")]
14    pub schedule: String,
15    #[serde(default = "main_window")]
16    pub window_id: String,
17    #[serde(default)]
18    pub inputs: BTreeMap<String, Value>,
19    #[serde(default = "deadline")]
20    pub deadline_ms: u64,
21    #[serde(default)]
22    pub defaults: WorkflowDefaults,
23    #[serde(default)]
24    pub evidence: EvidencePolicy,
25    pub steps: Vec<Step>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub goal: Option<Condition>,
28}
29fn strict() -> String {
30    "strict".into()
31}
32fn sequential() -> String {
33    "sequential".into()
34}
35fn main_window() -> String {
36    "main".into()
37}
38fn deadline() -> u64 {
39    60_000
40}
41fn step_timeout() -> u64 {
42    10_000
43}
44fn locator_timeout() -> u64 {
45    3_000
46}
47fn poll_interval() -> u64 {
48    100
49}
50fn evidence_grace() -> u64 {
51    2_000
52}
53fn max_inline() -> usize {
54    16_384
55}
56fn summary() -> String {
57    "summary".into()
58}
59fn scoped() -> String {
60    "scoped".into()
61}
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct WorkflowDefaults {
65    #[serde(default = "step_timeout")]
66    pub step_timeout_ms: u64,
67    #[serde(default = "locator_timeout")]
68    pub locator_timeout_ms: u64,
69    #[serde(default = "poll_interval")]
70    pub poll_interval_ms: u64,
71    #[serde(default = "evidence_grace")]
72    pub failure_evidence_grace_ms: u64,
73}
74impl Default for WorkflowDefaults {
75    fn default() -> Self {
76        Self {
77            step_timeout_ms: step_timeout(),
78            locator_timeout_ms: locator_timeout(),
79            poll_interval_ms: poll_interval(),
80            failure_evidence_grace_ms: evidence_grace(),
81        }
82    }
83}
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct EvidencePolicy {
87    #[serde(default = "summary")]
88    pub success: String,
89    #[serde(default = "scoped")]
90    pub failure: String,
91    #[serde(default = "max_inline")]
92    pub max_inline_bytes: usize,
93}
94impl Default for EvidencePolicy {
95    fn default() -> Self {
96        Self {
97            success: summary(),
98            failure: scoped(),
99            max_inline_bytes: max_inline(),
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize)]
105#[serde(rename_all = "camelCase")]
106pub struct Step {
107    pub id: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub window_id: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub timeout_ms: Option<u64>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub expect: Option<Condition>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub evidence: Option<EvidencePolicy>,
116    #[serde(flatten)]
117    pub op: StepOp,
118}
119// Deserialize common fields separately so the op union can reject fields that
120// belong to another op (serde flatten + deny_unknown_fields is not sufficient).
121impl<'de> Deserialize<'de> for Step {
122    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123        let mut fields = serde_json::Map::<String, Value>::deserialize(deserializer)?;
124        let id = fields
125            .remove("id")
126            .ok_or_else(|| serde::de::Error::missing_field("id"))?;
127        let mut take = |name: &str| -> Result<Option<Value>, D::Error> { Ok(fields.remove(name)) };
128        let window_id = take("windowId")?
129            .map(serde_json::from_value)
130            .transpose()
131            .map_err(serde::de::Error::custom)?;
132        let timeout_ms = take("timeoutMs")?
133            .map(serde_json::from_value)
134            .transpose()
135            .map_err(serde::de::Error::custom)?;
136        let expect = take("expect")?
137            .map(serde_json::from_value)
138            .transpose()
139            .map_err(serde::de::Error::custom)?;
140        let evidence = take("evidence")?
141            .map(serde_json::from_value)
142            .transpose()
143            .map_err(serde::de::Error::custom)?;
144        Ok(Self {
145            id: serde_json::from_value(id).map_err(serde::de::Error::custom)?,
146            window_id,
147            timeout_ms,
148            expect,
149            evidence,
150            op: serde_json::from_value(Value::Object(fields)).map_err(serde::de::Error::custom)?,
151        })
152    }
153}
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "op", rename_all = "camelCase", deny_unknown_fields)]
156pub enum StepOp {
157    Click {
158        target: Locator,
159    },
160    Fill {
161        target: Locator,
162        value: ValueExpr,
163    },
164    Type {
165        target: Locator,
166        value: ValueExpr,
167    },
168    Press {
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        target: Option<Locator>,
171        key: ValueExpr,
172    },
173    Wait {
174        condition: Condition,
175    },
176    Query {
177        target: Locator,
178        query: QueryKind,
179    },
180    Tool {
181        tool: String,
182        args: Value,
183        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184        bindings: BTreeMap<String, ValueExpr>,
185    },
186}
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct Locator {
190    pub by: LocatorBy,
191    pub value: ValueExpr,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub name: Option<ValueExpr>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub scope: Option<Box<Locator>>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub entity: Option<EntityLocator>,
198}
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub enum LocatorBy {
202    Role,
203    Label,
204    TestId,
205    Css,
206}
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct EntityLocator {
210    pub attribute: String,
211    pub value: ValueExpr,
212}
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
215pub enum QueryKind {
216    Value,
217    Text,
218    Attribute { name: String },
219}
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
222pub enum Condition {
223    Element {
224        target: Locator,
225        state: ElementState,
226    },
227    ValueEquals {
228        target: Locator,
229        expected: ValueExpr,
230    },
231    TextContains {
232        target: Locator,
233        expected: ValueExpr,
234    },
235    AttributeEquals {
236        target: Locator,
237        name: String,
238        expected: ValueExpr,
239    },
240    Result {
241        #[serde(rename = "stepId")]
242        step_id: String,
243        pointer: String,
244        operator: ResultOperator,
245        #[serde(default, skip_serializing_if = "Option::is_none")]
246        expected: Option<ValueExpr>,
247    },
248    All {
249        conditions: Vec<Condition>,
250    },
251    Any {
252        conditions: Vec<Condition>,
253    },
254}
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub enum ElementState {
258    Visible,
259    Hidden,
260    Attached,
261    Detached,
262    Enabled,
263    Editable,
264}
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub enum ResultOperator {
268    Eq,
269    Exists,
270    NonEmptyString,
271}
272impl WorkflowSpec {
273    pub fn canonical_value(&self) -> Result<Value, WorkflowError> {
274        let mut normalized = self.clone();
275        for step in &mut normalized.steps {
276            step.window_id
277                .get_or_insert_with(|| normalized.window_id.clone());
278            step.timeout_ms
279                .get_or_insert(normalized.defaults.step_timeout_ms);
280            step.evidence
281                .get_or_insert_with(|| normalized.evidence.clone());
282        }
283        let mut value = serde_json::to_value(normalized).map_err(|_| {
284            WorkflowError::new("invalid_spec", "validating", "Could not serialize workflow")
285        })?;
286        if let Some(obj) = value.as_object_mut() {
287            obj.remove("runKey");
288        }
289        Ok(value)
290    }
291}