Skip to main content

ironflow_api/routes/
plan_workflow.rs

1//! `POST /api/v1/workflows/:name/plan` — Build a workflow execution plan.
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::response::IntoResponse;
6use ironflow_auth::extractor::Authenticated;
7use ironflow_engine::error::EngineError;
8use ironflow_engine::plan::{
9    ConditionResult, DEFAULT_ESTIMATE_SAMPLE_RUNS, DEFAULT_PLAN_MAX_DEPTH, ExecutionPlan,
10    PlanOptions, PlannedStep,
11};
12use ironflow_store::entities::ApiKeyScope;
13use ironflow_store::entities::StepKind;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16
17use crate::authorization::AuthorizeExt;
18use crate::error::ApiError;
19use crate::response::ok;
20use crate::state::AppState;
21
22/// Highest sub-workflow expansion depth the API accepts.
23const MAX_PLAN_DEPTH: u32 = 10;
24
25/// Request body for building a workflow execution plan.
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
27#[derive(Debug, Default, Deserialize, Serialize)]
28pub struct PlanWorkflowRequest {
29    /// Input payload the plan is computed for. Defaults to `{}`.
30    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
31    #[serde(default)]
32    pub payload: Option<Value>,
33    /// How deep sub-workflows are expanded. Defaults to 3, capped at 10.
34    #[serde(default)]
35    pub max_depth: Option<u32>,
36    /// Estimate step durations from run history. Defaults to `true`.
37    #[serde(default)]
38    pub estimate_durations: Option<bool>,
39}
40
41/// Outcome of a branch condition as recorded by the planner.
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43#[derive(Debug, Serialize)]
44pub struct ConditionResponse {
45    /// Condition state: `evaluated`, `skipped` or `unevaluable`.
46    pub state: String,
47    /// Expression the handler declared, when the planner knows one.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub expression: Option<String>,
50    /// What the expression evaluated to, for an `evaluated` condition.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub value: Option<bool>,
53    /// Why the step is skipped, or why the condition cannot be evaluated.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub reason: Option<String>,
56}
57
58/// One step the planner expects the run to create.
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[derive(Debug, Serialize)]
61pub struct PlannedStepResponse {
62    /// Step name as the handler declares it.
63    pub name: String,
64    /// Step kind, or the name of a custom operation.
65    pub kind: String,
66    /// Workflow that owns this step.
67    pub workflow: String,
68    /// Sub-workflow nesting depth; `0` for the top-level workflow.
69    pub depth: u32,
70    /// Names of the steps this one runs after.
71    pub depends_on: Vec<String>,
72    /// Branch condition recorded for this step, when the handler declared one.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub condition: Option<ConditionResponse>,
75    /// Parallel wave this step belongs to, when it runs concurrently.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub parallel_group: Option<String>,
78    /// Average duration of this step in past completed runs, in milliseconds.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub estimated_duration_ms: Option<u64>,
81}
82
83/// The execution plan of one workflow for one input payload.
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[derive(Debug, Serialize)]
86pub struct ExecutionPlanResponse {
87    /// Workflow the plan was built for.
88    pub workflow: String,
89    /// Steps the run is expected to create, in execution order.
90    pub steps: Vec<PlannedStepResponse>,
91    /// Sum of the step estimates, counting each parallel wave once.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub estimated_duration_ms: Option<u64>,
94    /// Sub-workflow expansion depth used for this plan.
95    pub max_depth: u32,
96    /// `true` when the step cap or the depth limit cut the plan short.
97    pub truncated: bool,
98    /// Why the plan stopped early, when it did.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub incomplete_reason: Option<String>,
101}
102
103/// Render a [`StepKind`] the way the rest of the API does: a plain string.
104fn kind_label(kind: &StepKind) -> String {
105    match kind {
106        StepKind::Shell => "shell".to_string(),
107        StepKind::Http => "http".to_string(),
108        StepKind::Agent => "agent".to_string(),
109        StepKind::Workflow => "workflow".to_string(),
110        StepKind::Approval => "approval".to_string(),
111        StepKind::Decision => "decision".to_string(),
112        StepKind::HumanInput => "human_input".to_string(),
113        StepKind::Signal => "signal".to_string(),
114        StepKind::Custom(name) => name.clone(),
115    }
116}
117
118impl From<ConditionResult> for ConditionResponse {
119    fn from(condition: ConditionResult) -> Self {
120        match condition {
121            ConditionResult::Evaluated { expression, value } => Self {
122                state: "evaluated".to_string(),
123                expression: Some(expression),
124                value: Some(value),
125                reason: None,
126            },
127            ConditionResult::Skipped { reason } => Self {
128                state: "skipped".to_string(),
129                expression: None,
130                value: None,
131                reason: Some(reason),
132            },
133            ConditionResult::Unevaluable { expression, reason } => Self {
134                state: "unevaluable".to_string(),
135                expression: Some(expression),
136                value: None,
137                reason: Some(reason),
138            },
139        }
140    }
141}
142
143impl From<PlannedStep> for PlannedStepResponse {
144    fn from(step: PlannedStep) -> Self {
145        let estimated_duration_ms = step.estimated_duration_ms();
146        Self {
147            name: step.name,
148            kind: kind_label(&step.kind),
149            workflow: step.workflow,
150            depth: step.depth,
151            depends_on: step.depends_on,
152            condition: step.condition.map(ConditionResponse::from),
153            parallel_group: step.parallel_group,
154            estimated_duration_ms,
155        }
156    }
157}
158
159impl From<ExecutionPlan> for ExecutionPlanResponse {
160    fn from(plan: ExecutionPlan) -> Self {
161        let estimated_duration_ms = plan.estimated_duration_ms();
162        Self {
163            workflow: plan.workflow,
164            steps: plan
165                .steps
166                .into_iter()
167                .map(PlannedStepResponse::from)
168                .collect(),
169            estimated_duration_ms,
170            max_depth: plan.max_depth,
171            truncated: plan.truncated,
172            incomplete_reason: plan.incomplete_reason,
173        }
174    }
175}
176
177/// Build a workflow execution plan without running it.
178///
179/// # Errors
180///
181/// - 400 if `max_depth` is out of range or the payload is not a JSON object
182/// - 404 if the workflow is not registered
183#[cfg_attr(
184    feature = "openapi",
185    utoipa::path(
186        post,
187        path = "/api/v1/workflows/{name}/plan",
188        tags = ["workflows"],
189        params(("name" = String, Path, description = "Workflow name")),
190        request_body(content = PlanWorkflowRequest, description = "Input payload and planning options"),
191        responses(
192            (status = 200, description = "Execution plan", body = ExecutionPlanResponse),
193            (status = 400, description = "Invalid payload or max_depth"),
194            (status = 401, description = "Unauthorized"),
195            (status = 404, description = "Workflow not found")
196        ),
197        security(("Bearer" = []))
198    )
199)]
200pub async fn plan_workflow(
201    auth: Authenticated,
202    State(state): State<AppState>,
203    Path(name): Path<String>,
204    Json(req): Json<PlanWorkflowRequest>,
205) -> Result<impl IntoResponse, ApiError> {
206    auth.require(ApiKeyScope::WorkflowsRead, false)?;
207    if !state.engine.handler_names().contains(&name.as_str()) {
208        return Err(ApiError::WorkflowNotFound(name));
209    }
210
211    let max_depth = req.max_depth.unwrap_or(DEFAULT_PLAN_MAX_DEPTH);
212    if max_depth == 0 || max_depth > MAX_PLAN_DEPTH {
213        return Err(ApiError::BadRequest(format!(
214            "max_depth must be between 1 and {MAX_PLAN_DEPTH}"
215        )));
216    }
217
218    let payload = req.payload.unwrap_or_else(|| json!({}));
219    if !payload.is_object() {
220        return Err(ApiError::BadRequest(
221            "payload must be a JSON object".to_string(),
222        ));
223    }
224
225    let options = PlanOptions {
226        max_depth,
227        estimate_durations: req.estimate_durations.unwrap_or(true),
228        sample_runs: DEFAULT_ESTIMATE_SAMPLE_RUNS,
229    };
230
231    let plan = state
232        .engine
233        .plan_handler(&name, payload, options)
234        .await
235        .map_err(|e| match e {
236            EngineError::InvalidWorkflow(msg) => ApiError::BadRequest(msg),
237            EngineError::Store(err) => ApiError::from(err),
238            other => ApiError::Internal(other.to_string()),
239        })?;
240
241    Ok(ok(ExecutionPlanResponse::from(plan)))
242}
243
244#[cfg(test)]
245mod tests {
246    use std::sync::Arc;
247
248    use axum::Router;
249    use axum::body::Body;
250    use axum::http::{Request, StatusCode};
251    use axum::response::Response;
252    use axum::routing::post;
253    use http_body_util::BodyExt;
254    use ironflow_auth::jwt::JwtConfig;
255    use ironflow_core::providers::claude::ClaudeCodeProvider;
256    use ironflow_engine::config::{ShellConfig, StepConfig};
257    use ironflow_engine::context::WorkflowContext;
258    use ironflow_engine::engine::Engine;
259    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
260    use ironflow_engine::notify::Event;
261    use ironflow_store::memory::InMemoryStore;
262    use ironflow_store::models::RunFilter;
263    use serde_json::{Value as JsonValue, from_slice};
264    use tokio::sync::broadcast;
265    use tower::ServiceExt;
266
267    use super::*;
268    use crate::routes::test_helpers::create_user_auth_header;
269
270    struct PlannedWorkflow;
271
272    impl WorkflowHandler for PlannedWorkflow {
273        fn name(&self) -> &str {
274            "planned"
275        }
276
277        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
278            Box::pin(async move {
279                ctx.shell("build", ShellConfig::new("echo build")).await?;
280                ctx.parallel(
281                    vec![
282                        ("test", StepConfig::Shell(ShellConfig::new("echo test"))),
283                        ("lint", StepConfig::Shell(ShellConfig::new("echo lint"))),
284                    ],
285                    true,
286                )
287                .await?;
288                Ok(())
289            })
290        }
291    }
292
293    #[derive(Deserialize)]
294    struct DeployInput {
295        env: String,
296    }
297
298    struct ConditionalWorkflow;
299
300    impl WorkflowHandler for ConditionalWorkflow {
301        fn name(&self) -> &str {
302            "conditional"
303        }
304
305        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
306            Box::pin(async move {
307                if ctx
308                    .when("production run", |i: &DeployInput| i.env == "prod")
309                    .await?
310                {
311                    ctx.shell("deploy", ShellConfig::new("echo deploy")).await?;
312                } else {
313                    ctx.skip("deploy", "not prod").await?;
314                }
315                Ok(())
316            })
317        }
318    }
319
320    fn test_state() -> AppState {
321        let store = Arc::new(InMemoryStore::new());
322        let provider = Arc::new(ClaudeCodeProvider::new());
323        let mut engine = Engine::new(store.clone(), provider);
324        engine.register(PlannedWorkflow).unwrap();
325        engine.register(ConditionalWorkflow).unwrap();
326        let jwt_config = Arc::new(JwtConfig {
327            secret: "test-secret".to_string(),
328            access_token_ttl_secs: 900,
329            refresh_token_ttl_secs: 604800,
330            cookie_domain: None,
331            cookie_secure: false,
332        });
333        let (event_sender, _) = broadcast::channel::<Event>(1);
334        AppState::new(
335            store,
336            Arc::new(engine),
337            jwt_config,
338            "test-worker-token".to_string(),
339            event_sender,
340        )
341    }
342
343    fn app(state: AppState) -> Router {
344        Router::new()
345            .route("/api/v1/workflows/{name}/plan", post(plan_workflow))
346            .with_state(state)
347    }
348
349    fn plan_request(name: &str, auth: Option<&str>, body: JsonValue) -> Request<Body> {
350        let mut builder = Request::builder()
351            .method("POST")
352            .uri(format!("/api/v1/workflows/{name}/plan"))
353            .header("content-type", "application/json");
354        if let Some(header) = auth {
355            builder = builder.header("authorization", header);
356        }
357        builder.body(Body::from(body.to_string())).unwrap()
358    }
359
360    async fn body_json(response: Response) -> JsonValue {
361        let bytes = response.into_body().collect().await.unwrap().to_bytes();
362        from_slice(&bytes).unwrap()
363    }
364
365    #[tokio::test]
366    async fn plan_returns_steps_for_registered_workflow() {
367        let state = test_state();
368        let auth = create_user_auth_header(&state, "testuser", false).await;
369        let response = app(state)
370            .oneshot(plan_request("planned", Some(&auth), json!({})))
371            .await
372            .unwrap();
373
374        assert_eq!(response.status(), StatusCode::OK);
375        let body = body_json(response).await;
376        assert_eq!(body["data"]["workflow"], "planned");
377        let steps = body["data"]["steps"].as_array().unwrap();
378        assert_eq!(steps.len(), 3);
379        assert_eq!(steps[0]["name"], "build");
380        assert_eq!(steps[0]["kind"], "shell");
381        assert_eq!(steps[1]["name"], "test");
382        assert_eq!(steps[2]["name"], "lint");
383        assert_eq!(body["data"]["truncated"], false);
384    }
385
386    #[tokio::test]
387    async fn plan_marks_parallel_group() {
388        let state = test_state();
389        let auth = create_user_auth_header(&state, "testuser", false).await;
390        let response = app(state)
391            .oneshot(plan_request("planned", Some(&auth), json!({})))
392            .await
393            .unwrap();
394
395        let body = body_json(response).await;
396        let steps = body["data"]["steps"].as_array().unwrap();
397        assert!(steps[0].get("parallel_group").is_none());
398        assert_eq!(steps[1]["parallel_group"], "parallel-1");
399        assert_eq!(steps[2]["parallel_group"], "parallel-1");
400    }
401
402    #[tokio::test]
403    async fn plan_evaluates_condition_from_payload() {
404        let state = test_state();
405        let auth = create_user_auth_header(&state, "testuser", false).await;
406        let router = app(state);
407
408        let response = router
409            .clone()
410            .oneshot(plan_request(
411                "conditional",
412                Some(&auth),
413                json!({"payload": {"env": "prod"}}),
414            ))
415            .await
416            .unwrap();
417        let body = body_json(response).await;
418        let step = &body["data"]["steps"][0];
419        assert_eq!(step["kind"], "shell");
420        assert_eq!(step["condition"]["state"], "evaluated");
421        assert_eq!(step["condition"]["expression"], "production run");
422        assert_eq!(step["condition"]["value"], true);
423
424        let response = router
425            .oneshot(plan_request(
426                "conditional",
427                Some(&auth),
428                json!({"payload": {"env": "dev"}}),
429            ))
430            .await
431            .unwrap();
432        let body = body_json(response).await;
433        let step = &body["data"]["steps"][0];
434        assert_eq!(step["kind"], "skip");
435        assert_eq!(step["condition"]["state"], "skipped");
436        assert_eq!(step["condition"]["reason"], "not prod");
437    }
438
439    #[tokio::test]
440    async fn plan_unknown_workflow_returns_404() {
441        let state = test_state();
442        let auth = create_user_auth_header(&state, "testuser", false).await;
443        let response = app(state)
444            .oneshot(plan_request("nonexistent", Some(&auth), json!({})))
445            .await
446            .unwrap();
447
448        assert_eq!(response.status(), StatusCode::NOT_FOUND);
449    }
450
451    #[tokio::test]
452    async fn plan_rejects_zero_and_excessive_max_depth() {
453        let state = test_state();
454        let auth = create_user_auth_header(&state, "testuser", false).await;
455        let router = app(state);
456
457        let response = router
458            .clone()
459            .oneshot(plan_request(
460                "planned",
461                Some(&auth),
462                json!({"max_depth": 0}),
463            ))
464            .await
465            .unwrap();
466        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
467
468        let response = router
469            .oneshot(plan_request(
470                "planned",
471                Some(&auth),
472                json!({"max_depth": 11}),
473            ))
474            .await
475            .unwrap();
476        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
477    }
478
479    #[tokio::test]
480    async fn plan_rejects_non_object_payload() {
481        let state = test_state();
482        let auth = create_user_auth_header(&state, "testuser", false).await;
483        let response = app(state)
484            .oneshot(plan_request(
485                "planned",
486                Some(&auth),
487                json!({"payload": [1, 2, 3]}),
488            ))
489            .await
490            .unwrap();
491
492        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
493    }
494
495    #[tokio::test]
496    async fn plan_requires_authentication() {
497        let state = test_state();
498        let response = app(state)
499            .oneshot(plan_request("planned", None, json!({})))
500            .await
501            .unwrap();
502
503        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
504    }
505
506    #[tokio::test]
507    async fn plan_does_not_create_a_run() {
508        let state = test_state();
509        let store = state.store.clone();
510        let auth = create_user_auth_header(&state, "testuser", false).await;
511        let response = app(state)
512            .oneshot(plan_request("planned", Some(&auth), json!({})))
513            .await
514            .unwrap();
515        assert_eq!(response.status(), StatusCode::OK);
516
517        let runs = store.list_runs(RunFilter::default(), 1, 10).await.unwrap();
518        assert!(runs.items.is_empty());
519    }
520}