Skip to main content

a3s_flow/engine/
hooks.rs

1use crate::error::{FlowError, Result};
2use crate::model::{FlowEvent, HookStatus};
3
4use super::{validation::is_event_conflict, FlowEngine};
5
6impl FlowEngine {
7    /// Resume a hook with an external payload.
8    ///
9    /// Redelivery through a durable outbox is idempotent when the hook already
10    /// contains the same payload, including after the run becomes terminal. A
11    /// different payload or a different terminal hook resolution is rejected
12    /// explicitly instead of being mistaken for the committed outcome.
13    pub async fn resume_hook(
14        &self,
15        run_id: &str,
16        hook_id: &str,
17        payload: serde_json::Value,
18    ) -> Result<()> {
19        for _ in 0..self.max_replay_iterations {
20            let snapshot = self.snapshot(run_id).await?;
21            let Some(hook) = snapshot.hooks.get(hook_id) else {
22                if snapshot.status.is_terminal() {
23                    return Err(FlowError::RunTerminal(run_id.to_string()));
24                }
25                return Err(FlowError::InvalidTransition(format!(
26                    "hook {hook_id} does not exist for run {run_id}"
27                )));
28            };
29            let hook_status = hook.status;
30            let recorded_payload = hook.payload.clone();
31
32            match hook_status {
33                HookStatus::Active => {
34                    if snapshot.status.is_terminal() {
35                        return Err(FlowError::RunTerminal(run_id.to_string()));
36                    }
37                    self.ensure_runtime_build_available(run_id, &snapshot.spec)?;
38                    match self
39                        .record_event_at(
40                            run_id,
41                            snapshot.last_sequence,
42                            FlowEvent::HookReceived {
43                                hook_id: hook_id.to_string(),
44                                payload: payload.clone(),
45                            },
46                        )
47                        .await
48                    {
49                        Ok(_) => {}
50                        Err(error) if is_event_conflict(&error) => continue,
51                        Err(error) => return Err(error),
52                    }
53                }
54                HookStatus::Received => {
55                    if recorded_payload.as_ref() != Some(&payload) {
56                        return Err(hook_conflict(
57                            run_id,
58                            hook_id,
59                            "was already resumed with a different payload",
60                        ));
61                    }
62                    if snapshot.status.is_terminal() {
63                        return Ok(());
64                    }
65                    self.ensure_runtime_build_available(run_id, &snapshot.spec)?;
66                }
67                HookStatus::Disposed => {
68                    return Err(hook_conflict(run_id, hook_id, "was already disposed"));
69                }
70                HookStatus::Cancelled => {
71                    return Err(hook_conflict(run_id, hook_id, "was cancelled"));
72                }
73            }
74
75            match self.drive(run_id).await {
76                Ok(_) => return Ok(()),
77                Err(error) if is_event_conflict(&error) => continue,
78                Err(error) => return Err(error),
79            }
80        }
81
82        Err(FlowError::ReplayLimitExceeded(self.max_replay_iterations))
83    }
84
85    /// Dispose an active hook without accepting a callback payload.
86    ///
87    /// This is useful when a host withdraws an approval request, expires a
88    /// webhook token, or closes an external callback route. Redelivery is
89    /// idempotent when the hook was already disposed. A received or cancelled
90    /// hook conflicts with disposal and cannot be reported as successful.
91    pub async fn dispose_hook(&self, run_id: &str, hook_id: &str) -> Result<()> {
92        for _ in 0..self.max_replay_iterations {
93            let snapshot = self.snapshot(run_id).await?;
94            let Some(hook) = snapshot.hooks.get(hook_id) else {
95                if snapshot.status.is_terminal() {
96                    return Err(FlowError::RunTerminal(run_id.to_string()));
97                }
98                return Err(FlowError::InvalidTransition(format!(
99                    "hook {hook_id} does not exist for run {run_id}"
100                )));
101            };
102
103            match hook.status {
104                HookStatus::Active => {
105                    if snapshot.status.is_terminal() {
106                        return Err(FlowError::RunTerminal(run_id.to_string()));
107                    }
108                    self.ensure_runtime_build_available(run_id, &snapshot.spec)?;
109                    match self
110                        .record_event_at(
111                            run_id,
112                            snapshot.last_sequence,
113                            FlowEvent::HookDisposed {
114                                hook_id: hook_id.to_string(),
115                            },
116                        )
117                        .await
118                    {
119                        Ok(_) => {}
120                        Err(error) if is_event_conflict(&error) => continue,
121                        Err(error) => return Err(error),
122                    }
123                }
124                HookStatus::Disposed => {
125                    if snapshot.status.is_terminal() {
126                        return Ok(());
127                    }
128                    self.ensure_runtime_build_available(run_id, &snapshot.spec)?;
129                }
130                HookStatus::Received => {
131                    return Err(hook_conflict(run_id, hook_id, "was already resumed"));
132                }
133                HookStatus::Cancelled => {
134                    return Err(hook_conflict(run_id, hook_id, "was cancelled"));
135                }
136            }
137
138            match self.drive(run_id).await {
139                Ok(_) => return Ok(()),
140                Err(error) if is_event_conflict(&error) => continue,
141                Err(error) => return Err(error),
142            }
143        }
144
145        Err(FlowError::ReplayLimitExceeded(self.max_replay_iterations))
146    }
147
148    /// Resume an active hook by its external token.
149    ///
150    /// Token lookup intentionally covers only active hooks. Durable consumers
151    /// that need idempotent redelivery after resolution must retain the stable
152    /// run and hook identities and call [`Self::resume_hook`].
153    pub async fn resume_hook_by_token(
154        &self,
155        token: &str,
156        payload: serde_json::Value,
157    ) -> Result<(String, String)> {
158        let mut matches = self
159            .store
160            .find_active_hooks_by_token(token)
161            .await?
162            .into_iter()
163            .map(|active| (active.run_id, active.hook.hook_id))
164            .collect::<Vec<_>>();
165
166        match matches.len() {
167            0 => Err(FlowError::HookTokenNotFound(token.to_string())),
168            1 => {
169                let (run_id, hook_id) = matches.remove(0);
170                self.resume_hook(&run_id, &hook_id, payload).await?;
171                Ok((run_id, hook_id))
172            }
173            _ => Err(FlowError::InvalidTransition(
174                "hook token is active in multiple runs (value redacted)".to_string(),
175            )),
176        }
177    }
178
179    /// Dispose an active hook by its external token.
180    ///
181    /// This mirrors [`resume_hook_by_token`](Self::resume_hook_by_token) for
182    /// callback routers that only know the public token.
183    pub async fn dispose_hook_by_token(&self, token: &str) -> Result<(String, String)> {
184        let mut matches = self
185            .store
186            .find_active_hooks_by_token(token)
187            .await?
188            .into_iter()
189            .map(|active| (active.run_id, active.hook.hook_id))
190            .collect::<Vec<_>>();
191
192        match matches.len() {
193            0 => Err(FlowError::HookTokenNotFound(token.to_string())),
194            1 => {
195                let (run_id, hook_id) = matches.remove(0);
196                self.dispose_hook(&run_id, &hook_id).await?;
197                Ok((run_id, hook_id))
198            }
199            _ => Err(FlowError::InvalidTransition(
200                "hook token is active in multiple runs (value redacted)".to_string(),
201            )),
202        }
203    }
204}
205
206fn hook_conflict(run_id: &str, hook_id: &str, reason: &str) -> FlowError {
207    FlowError::HookConflict {
208        run_id: run_id.to_string(),
209        hook_id: hook_id.to_string(),
210        reason: reason.to_string(),
211    }
212}