ironflow_engine/context/steps/human_input.rs
1//! Typed human input step for [`WorkflowContext`].
2
3use chrono::{TimeDelta, Utc};
4use schemars::{JsonSchema, schema_for};
5use serde::de::DeserializeOwned;
6use serde_json::{Value, from_value, json, to_value};
7use tracing::info;
8
9use ironflow_store::models::{NewStep, Step, StepKind, StepStatus, StepUpdate, step_trace_id};
10
11use crate::config::{Approvers, HUMAN_INPUT_SCHEMA_KEY, HumanInputConfig};
12use crate::context::WorkflowContext;
13use crate::context::lifecycle::check_replay_identity;
14use crate::error::EngineError;
15use crate::executor::HumanInputOutcome;
16use crate::notify::{WorkflowEvent, WorkflowInputRequiredEvent};
17use crate::plan::lock_plan;
18
19impl WorkflowContext {
20 /// Ask a human for a typed answer and suspend the run until it is given.
21 ///
22 /// On first execution, records a human input step carrying the JSON schema
23 /// of `T` and returns [`EngineError::HumanInputRequired`] to suspend the
24 /// run. The engine transitions the run to `AwaitingApproval`. The answer is
25 /// posted to `POST /api/v1/runs/{id}/steps/{step_id}/input`, validated
26 /// against the schema, and the run resumes.
27 ///
28 /// On resume, the step is replayed and the stored answer is deserialized
29 /// into `T`. A rejected input (`POST .../steps/{step_id}/reject`) returns
30 /// [`EngineError::HumanInputRejected`] so the handler decides what happens
31 /// next. An answer given in an earlier attempt is carried over to a retry.
32 ///
33 /// The config reuses the approval gate machinery: deadline, escalation
34 /// policy, assignee and the [`Approvers`] allowed to answer.
35 ///
36 /// While planning, the step is recorded and never suspends: `T` is
37 /// deserialized from `{}` when it accepts that (for example with
38 /// `#[serde(default)]`).
39 ///
40 /// # Errors
41 ///
42 /// Returns [`EngineError::HumanInputRequired`] to pause the run until an
43 /// answer is given. Returns [`EngineError::HumanInputRejected`] when the
44 /// input was rejected. Returns [`EngineError::StepConfig`] when the stored
45 /// answer does not match `T`, or while planning when `T` cannot be built
46 /// from `{}`. Returns [`EngineError::ReplayDivergence`] when the step
47 /// recorded at this position has a different name or kind. Returns other
48 /// [`EngineError`] variants on store failures.
49 ///
50 /// # Examples
51 ///
52 /// ```no_run
53 /// use ironflow_engine::config::HumanInputConfig;
54 /// use ironflow_engine::context::WorkflowContext;
55 /// use ironflow_engine::error::EngineError;
56 /// use schemars::JsonSchema;
57 /// use serde::Deserialize;
58 ///
59 /// #[derive(Deserialize, JsonSchema)]
60 /// struct Answers {
61 /// answers: Vec<String>,
62 /// }
63 ///
64 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
65 /// let answers: Answers = ctx
66 /// .human_input("clarify", HumanInputConfig::new("Answer the clarification questions"))
67 /// .await?;
68 /// assert!(answers.answers.len() < 100);
69 /// # Ok(())
70 /// # }
71 /// ```
72 pub async fn human_input<T: DeserializeOwned + JsonSchema>(
73 &mut self,
74 name: &str,
75 config: HumanInputConfig,
76 ) -> Result<T, EngineError> {
77 let schema = to_value(schema_for!(T))?;
78
79 // Plan mode: record the step and continue. Planning must never suspend.
80 if let Some(plan) = self.plan().cloned() {
81 self.position += 1;
82 {
83 let mut recorder = lock_plan(&plan);
84 recorder.record_or_stop(name, StepKind::HumanInput, &self.workflow_name, None)?;
85 recorder.set_last(vec![name.to_string()]);
86 }
87 return from_value(json!({})).map_err(|e| {
88 EngineError::StepConfig(format!(
89 "human input '{name}' has no answer while planning: {e}"
90 ))
91 });
92 }
93
94 let value = self.human_input_value(name, &config, schema).await?;
95 from_value::<T>(value).map_err(|e| {
96 EngineError::StepConfig(format!(
97 "human input '{name}' answer does not match the expected type: {e}"
98 ))
99 })
100 }
101
102 /// Replay, carry over, intercept or open a human input step and return the
103 /// raw answer.
104 async fn human_input_value(
105 &mut self,
106 name: &str,
107 config: &HumanInputConfig,
108 schema: Value,
109 ) -> Result<Value, EngineError> {
110 let position = self.position;
111 self.position += 1;
112
113 // Replay: the step exists from a prior execution of this attempt.
114 if let Some(existing) = self.replay_steps.get(&position).cloned() {
115 check_replay_identity(&existing, position, name, &StepKind::HumanInput)?;
116 return self
117 .human_input_replay(name, config, position, existing)
118 .await;
119 }
120
121 // Carried over: a human already answered this input in an earlier
122 // attempt. Record a fresh completed step so each attempt keeps a
123 // complete DAG, and continue with the same answer.
124 if let Some((answered_in, value)) = self.answered_inputs.get(&position).cloned() {
125 let step = self
126 .create_human_input_step(name, position, config, &schema)
127 .await?;
128 let now = Utc::now();
129 self.start_step(step.id, now).await?;
130 self.store
131 .update_step(
132 step.id,
133 StepUpdate {
134 status: Some(StepStatus::Completed),
135 output: Some(value.clone()),
136 completed_at: Some(now),
137 ..StepUpdate::default()
138 },
139 )
140 .await?;
141
142 self.last_step_ids = vec![step.id];
143 info!(
144 run_id = %self.run_id,
145 step = %name,
146 position,
147 answered_in_attempt = answered_in,
148 attempt = self.attempt,
149 "human input carried over from a previous attempt"
150 );
151 return Ok(value);
152 }
153
154 // Recorded only when the input opens: the stored requirement is the
155 // source of truth from here.
156 let requirement = config.approvers().map(Approvers::to_requirement);
157
158 // An interceptor answers inline: the run neither suspends nor waits.
159 if let Some(interceptor) = self.interceptor.clone()
160 && let Some(outcome) = interceptor.intercept_human_input(name, config, &schema)
161 {
162 let step = self
163 .create_human_input_step(name, position, config, &schema)
164 .await?;
165 let now = Utc::now();
166 self.start_step(step.id, now).await?;
167 self.last_step_ids = vec![step.id];
168
169 return match outcome {
170 HumanInputOutcome::Provided(value) => {
171 self.store
172 .update_step(
173 step.id,
174 StepUpdate {
175 status: Some(StepStatus::Completed),
176 output: Some(value.clone()),
177 approval_requirement: requirement,
178 completed_at: Some(now),
179 ..StepUpdate::default()
180 },
181 )
182 .await?;
183 info!(
184 run_id = %self.run_id,
185 step = %name,
186 position,
187 "human input provided by the step interceptor"
188 );
189 Ok(value)
190 }
191 HumanInputOutcome::Rejected { reason } => {
192 // The step FSM only reaches Rejected from AwaitingApproval.
193 self.store
194 .update_step(
195 step.id,
196 StepUpdate {
197 status: Some(StepStatus::AwaitingApproval),
198 approval_requirement: requirement,
199 ..StepUpdate::default()
200 },
201 )
202 .await?;
203 self.store
204 .update_step(
205 step.id,
206 StepUpdate {
207 status: Some(StepStatus::Rejected),
208 error: Some(reason.clone()),
209 completed_at: Some(Utc::now()),
210 ..StepUpdate::default()
211 },
212 )
213 .await?;
214 info!(
215 run_id = %self.run_id,
216 step = %name,
217 position,
218 %reason,
219 "human input rejected by the step interceptor"
220 );
221 Err(EngineError::HumanInputRejected {
222 run_id: self.run_id,
223 step_id: step.id,
224 reason,
225 })
226 }
227 };
228 }
229
230 // First execution: create the step, arm the gate and suspend.
231 let step = self
232 .create_human_input_step(name, position, config, &schema)
233 .await?;
234 self.start_step(step.id, Utc::now()).await?;
235
236 let deadline_at = config
237 .effective_deadline_secs()
238 .map(|secs| Utc::now() + TimeDelta::seconds(secs as i64));
239
240 self.store
241 .update_step(
242 step.id,
243 StepUpdate {
244 status: Some(StepStatus::AwaitingApproval),
245 approval_deadline_at: deadline_at,
246 approval_stage: Some(0),
247 approval_assignee: config.assignee().cloned(),
248 approval_requirement: requirement,
249 ..StepUpdate::default()
250 },
251 )
252 .await?;
253
254 self.last_step_ids = vec![step.id];
255
256 if let Some(ref bus) = self.event_bus {
257 bus.publish(
258 self.run_id,
259 WorkflowEvent::InputRequired(WorkflowInputRequiredEvent {
260 run_id: self.run_id,
261 step_id: step.id,
262 step_name: name.to_string(),
263 step_index: position,
264 message: config.message().to_string(),
265 schema,
266 }),
267 );
268 }
269
270 info!(
271 run_id = %self.run_id,
272 step = %name,
273 position,
274 "human input requested"
275 );
276 Err(EngineError::HumanInputRequired {
277 run_id: self.run_id,
278 step_id: step.id,
279 message: config.message().to_string(),
280 })
281 }
282
283 /// Replay a human input step recorded in this attempt.
284 async fn human_input_replay(
285 &mut self,
286 name: &str,
287 config: &HumanInputConfig,
288 position: u32,
289 existing: Step,
290 ) -> Result<Value, EngineError> {
291 self.last_step_ids = vec![existing.id];
292
293 match existing.status.state {
294 StepStatus::Completed => {
295 info!(
296 run_id = %self.run_id,
297 step = %name,
298 position,
299 "human input replayed (answered)"
300 );
301 existing.output.ok_or_else(|| {
302 EngineError::StepConfig(format!("human input '{name}' has no stored answer"))
303 })
304 }
305 StepStatus::Rejected => {
306 info!(
307 run_id = %self.run_id,
308 step = %name,
309 position,
310 "human input replayed (rejected)"
311 );
312 Err(EngineError::HumanInputRejected {
313 run_id: self.run_id,
314 step_id: existing.id,
315 reason: existing
316 .error
317 .unwrap_or_else(|| "input rejected".to_string()),
318 })
319 }
320 state => {
321 // Resumed without an answer (or after a crash mid-open): the
322 // step keeps waiting, no new step is created.
323 if state == StepStatus::Running {
324 self.store
325 .update_step(
326 existing.id,
327 StepUpdate {
328 status: Some(StepStatus::AwaitingApproval),
329 ..StepUpdate::default()
330 },
331 )
332 .await?;
333 }
334 info!(
335 run_id = %self.run_id,
336 step = %name,
337 position,
338 "human input still unanswered, suspending again"
339 );
340 Err(EngineError::HumanInputRequired {
341 run_id: self.run_id,
342 step_id: existing.id,
343 message: config.message().to_string(),
344 })
345 }
346 }
347 }
348
349 /// Create the step record of a human input.
350 async fn create_human_input_step(
351 &self,
352 name: &str,
353 position: u32,
354 config: &HumanInputConfig,
355 schema: &Value,
356 ) -> Result<Step, EngineError> {
357 let trace_id = step_trace_id(self.run_id, name, position);
358 Ok(self
359 .store
360 .create_step(NewStep {
361 run_id: self.run_id,
362 trace_id,
363 name: name.to_string(),
364 kind: StepKind::HumanInput,
365 position,
366 input: Some(stored_input(config, schema)?),
367 is_error_handler: false,
368 })
369 .await?)
370 }
371}
372
373/// The stored step input: the flattened config plus the answer schema under
374/// [`HUMAN_INPUT_SCHEMA_KEY`].
375fn stored_input(config: &HumanInputConfig, schema: &Value) -> Result<Value, EngineError> {
376 let mut input = to_value(config)?;
377 if let Some(object) = input.as_object_mut() {
378 object.insert(HUMAN_INPUT_SCHEMA_KEY.to_string(), schema.clone());
379 }
380 Ok(input)
381}