Skip to main content

a3s_flow/model/
operation.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::error::{FlowError, Result};
5
6use super::JsonValue;
7
8/// Durable request for a workflow to stop through its cleanup-aware path.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct CancellationRequest {
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub reason: Option<String>,
13}
14
15impl CancellationRequest {
16    pub fn new(reason: Option<String>) -> Self {
17        Self { reason }
18    }
19}
20
21/// Projected cancellation request with its durable event position.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct CancellationRequestSnapshot {
24    pub request: CancellationRequest,
25    pub requested_at: DateTime<Utc>,
26    pub sequence: u64,
27}
28
29/// A durable, idempotently identified progress update.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct WorkflowProgress {
32    pub progress_id: String,
33    pub completed: u64,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub total: Option<u64>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub message: Option<String>,
38    #[serde(default, skip_serializing_if = "JsonValue::is_null")]
39    pub details: JsonValue,
40}
41
42impl WorkflowProgress {
43    pub fn new(progress_id: impl Into<String>, completed: u64) -> Self {
44        Self {
45            progress_id: progress_id.into(),
46            completed,
47            total: None,
48            message: None,
49            details: JsonValue::Null,
50        }
51    }
52
53    pub fn with_total(mut self, total: u64) -> Self {
54        self.total = Some(total);
55        self
56    }
57
58    pub fn with_message(mut self, message: impl Into<String>) -> Self {
59        self.message = Some(message.into());
60        self
61    }
62
63    pub fn with_details(mut self, details: JsonValue) -> Self {
64        self.details = details;
65        self
66    }
67
68    pub(crate) fn validate(&self) -> Result<()> {
69        if self.progress_id.trim().is_empty() {
70            return Err(FlowError::InvalidTransition(
71                "workflow progress id must not be empty".to_string(),
72            ));
73        }
74        if self
75            .total
76            .is_some_and(|total| total == 0 || self.completed > total)
77        {
78            return Err(FlowError::InvalidTransition(format!(
79                "workflow progress {} must satisfy completed <= total and total > 0",
80                self.progress_id
81            )));
82        }
83        Ok(())
84    }
85}
86
87/// Durable reference from a parent workflow to a child operation.
88///
89/// `flow_run_id` is set only when the child is another A3S Flow run. The
90/// reference itself does not imply automatic cancellation; the parent
91/// workflow owns propagation through durable, idempotent cleanup steps.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct ChildOperationReference {
94    pub reference_id: String,
95    pub kind: String,
96    pub operation_id: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub flow_run_id: Option<String>,
99    #[serde(default, skip_serializing_if = "JsonValue::is_null")]
100    pub metadata: JsonValue,
101}
102
103impl ChildOperationReference {
104    pub fn new(
105        reference_id: impl Into<String>,
106        kind: impl Into<String>,
107        operation_id: impl Into<String>,
108    ) -> Self {
109        Self {
110            reference_id: reference_id.into(),
111            kind: kind.into(),
112            operation_id: operation_id.into(),
113            flow_run_id: None,
114            metadata: JsonValue::Null,
115        }
116    }
117
118    pub fn with_flow_run_id(mut self, flow_run_id: impl Into<String>) -> Self {
119        self.flow_run_id = Some(flow_run_id.into());
120        self
121    }
122
123    pub fn with_metadata(mut self, metadata: JsonValue) -> Self {
124        self.metadata = metadata;
125        self
126    }
127
128    pub(crate) fn validate(&self) -> Result<()> {
129        if self.reference_id.trim().is_empty() {
130            return Err(FlowError::InvalidTransition(
131                "child operation reference id must not be empty".to_string(),
132            ));
133        }
134        if self.kind.trim().is_empty() {
135            return Err(FlowError::InvalidTransition(format!(
136                "child operation {} kind must not be empty",
137                self.reference_id
138            )));
139        }
140        if self.operation_id.trim().is_empty() {
141            return Err(FlowError::InvalidTransition(format!(
142                "child operation {} operation id must not be empty",
143                self.reference_id
144            )));
145        }
146        if self
147            .flow_run_id
148            .as_deref()
149            .is_some_and(|run_id| run_id.trim().is_empty())
150        {
151            return Err(FlowError::InvalidTransition(format!(
152                "child operation {} Flow run id must not be empty",
153                self.reference_id
154            )));
155        }
156        Ok(())
157    }
158}
159
160/// Typed terminal result projected from the final run event.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162#[serde(tag = "type", rename_all = "snake_case")]
163pub enum WorkflowTerminalOutcome {
164    Completed {
165        output: JsonValue,
166    },
167    Failed {
168        error: String,
169    },
170    Cancelled {
171        #[serde(default, skip_serializing_if = "Option::is_none")]
172        reason: Option<String>,
173    },
174    TimedOut {
175        deadline: DateTime<Utc>,
176        #[serde(default, skip_serializing_if = "Option::is_none")]
177        reason: Option<String>,
178    },
179    RetryExhausted {
180        step_id: String,
181        attempt: u32,
182        error: String,
183    },
184    HostShutdown {
185        #[serde(default, skip_serializing_if = "Option::is_none")]
186        reason: Option<String>,
187    },
188}