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