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, DEFAULT_PLAN_TIMEOUT,
10    ExecutionPlan, 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::SignalSend => "signal_send".to_string(),
115        StepKind::Custom(name) => name.clone(),
116    }
117}
118
119impl From<ConditionResult> for ConditionResponse {
120    fn from(condition: ConditionResult) -> Self {
121        match condition {
122            ConditionResult::Evaluated { expression, value } => Self {
123                state: "evaluated".to_string(),
124                expression: Some(expression),
125                value: Some(value),
126                reason: None,
127            },
128            ConditionResult::Skipped { reason } => Self {
129                state: "skipped".to_string(),
130                expression: None,
131                value: None,
132                reason: Some(reason),
133            },
134            ConditionResult::Unevaluable { expression, reason } => Self {
135                state: "unevaluable".to_string(),
136                expression: Some(expression),
137                value: None,
138                reason: Some(reason),
139            },
140        }
141    }
142}
143
144impl From<PlannedStep> for PlannedStepResponse {
145    fn from(step: PlannedStep) -> Self {
146        let estimated_duration_ms = step.estimated_duration_ms();
147        Self {
148            name: step.name,
149            kind: kind_label(&step.kind),
150            workflow: step.workflow,
151            depth: step.depth,
152            depends_on: step.depends_on,
153            condition: step.condition.map(ConditionResponse::from),
154            parallel_group: step.parallel_group,
155            estimated_duration_ms,
156        }
157    }
158}
159
160impl From<ExecutionPlan> for ExecutionPlanResponse {
161    fn from(plan: ExecutionPlan) -> Self {
162        let estimated_duration_ms = plan.estimated_duration_ms();
163        Self {
164            workflow: plan.workflow,
165            steps: plan
166                .steps
167                .into_iter()
168                .map(PlannedStepResponse::from)
169                .collect(),
170            estimated_duration_ms,
171            max_depth: plan.max_depth,
172            truncated: plan.truncated,
173            incomplete_reason: plan.incomplete_reason,
174        }
175    }
176}
177
178/// Build a workflow execution plan without running it.
179///
180/// # Errors
181///
182/// - 400 if `max_depth` is out of range or the payload is not a JSON object
183/// - 404 if the workflow is not registered
184#[cfg_attr(
185    feature = "openapi",
186    utoipa::path(
187        post,
188        path = "/api/v1/workflows/{name}/plan",
189        tags = ["workflows"],
190        params(("name" = String, Path, description = "Workflow name")),
191        request_body(content = PlanWorkflowRequest, description = "Input payload and planning options"),
192        responses(
193            (status = 200, description = "Execution plan", body = ExecutionPlanResponse),
194            (status = 400, description = "Invalid payload or max_depth"),
195            (status = 401, description = "Unauthorized"),
196            (status = 404, description = "Workflow not found")
197        ),
198        security(("Bearer" = []))
199    )
200)]
201pub async fn plan_workflow(
202    auth: Authenticated,
203    State(state): State<AppState>,
204    Path(name): Path<String>,
205    Json(req): Json<PlanWorkflowRequest>,
206) -> Result<impl IntoResponse, ApiError> {
207    auth.require(ApiKeyScope::WorkflowsRead, false)?;
208    if !state.engine.handler_names().contains(&name.as_str()) {
209        return Err(ApiError::WorkflowNotFound(name));
210    }
211
212    let max_depth = req.max_depth.unwrap_or(DEFAULT_PLAN_MAX_DEPTH);
213    if max_depth == 0 || max_depth > MAX_PLAN_DEPTH {
214        return Err(ApiError::BadRequest(format!(
215            "max_depth must be between 1 and {MAX_PLAN_DEPTH}"
216        )));
217    }
218
219    let payload = req.payload.unwrap_or_else(|| json!({}));
220    if !payload.is_object() {
221        return Err(ApiError::BadRequest(
222            "payload must be a JSON object".to_string(),
223        ));
224    }
225
226    let options = PlanOptions {
227        max_depth,
228        estimate_durations: req.estimate_durations.unwrap_or(true),
229        sample_runs: DEFAULT_ESTIMATE_SAMPLE_RUNS,
230        timeout: DEFAULT_PLAN_TIMEOUT,
231    };
232
233    let plan = state
234        .engine
235        .plan_handler(&name, payload, options)
236        .await
237        .map_err(|e| match e {
238            EngineError::InvalidWorkflow(msg) => ApiError::BadRequest(msg),
239            EngineError::Store(err) => ApiError::from(err),
240            other => ApiError::Internal(other.to_string()),
241        })?;
242
243    Ok(ok(ExecutionPlanResponse::from(plan)))
244}
245
246#[cfg(test)]
247mod tests {
248    use std::sync::Arc;
249
250    use axum::Router;
251    use axum::body::Body;
252    use axum::http::{Request, StatusCode};
253    use axum::response::Response;
254    use axum::routing::post;
255    use http_body_util::BodyExt;
256    use ironflow_auth::jwt::JwtConfig;
257    use ironflow_core::providers::claude::ClaudeCodeProvider;
258    use ironflow_engine::config::{ShellConfig, StepConfig};
259    use ironflow_engine::context::WorkflowContext;
260    use ironflow_engine::engine::Engine;
261    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
262    use ironflow_engine::notify::Event;
263    use ironflow_store::memory::InMemoryStore;
264    use ironflow_store::models::RunFilter;
265    use serde_json::{Value as JsonValue, from_slice};
266    use tokio::sync::broadcast;
267    use tower::ServiceExt;
268
269    use super::*;
270    use crate::routes::test_helpers::create_user_auth_header;
271
272    struct PlannedWorkflow;
273
274    impl WorkflowHandler for PlannedWorkflow {
275        fn name(&self) -> &str {
276            "planned"
277        }
278
279        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
280            Box::pin(async move {
281                ctx.shell("build", ShellConfig::new("echo build")).await?;
282                ctx.parallel(
283                    vec![
284                        ("test", StepConfig::Shell(ShellConfig::new("echo test"))),
285                        ("lint", StepConfig::Shell(ShellConfig::new("echo lint"))),
286                    ],
287                    true,
288                )
289                .await?;
290                Ok(())
291            })
292        }
293    }
294
295    #[derive(Deserialize)]
296    struct DeployInput {
297        env: String,
298    }
299
300    struct ConditionalWorkflow;
301
302    impl WorkflowHandler for ConditionalWorkflow {
303        fn name(&self) -> &str {
304            "conditional"
305        }
306
307        fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
308            Box::pin(async move {
309                if ctx
310                    .when("production run", |i: &DeployInput| i.env == "prod")
311                    .await?
312                {
313                    ctx.shell("deploy", ShellConfig::new("echo deploy")).await?;
314                } else {
315                    ctx.skip("deploy", "not prod").await?;
316                }
317                Ok(())
318            })
319        }
320    }
321
322    fn test_state() -> AppState {
323        let store = Arc::new(InMemoryStore::new());
324        let provider = Arc::new(ClaudeCodeProvider::new());
325        let mut engine = Engine::new(store.clone(), provider);
326        engine.register(PlannedWorkflow).unwrap();
327        engine.register(ConditionalWorkflow).unwrap();
328        let jwt_config = Arc::new(JwtConfig {
329            secret: "test-secret".to_string(),
330            access_token_ttl_secs: 900,
331            refresh_token_ttl_secs: 604800,
332            cookie_domain: None,
333            cookie_secure: false,
334        });
335        let (event_sender, _) = broadcast::channel::<Event>(1);
336        AppState::new(
337            store,
338            Arc::new(engine),
339            jwt_config,
340            "test-worker-token".to_string(),
341            event_sender,
342        )
343    }
344
345    fn app(state: AppState) -> Router {
346        Router::new()
347            .route("/api/v1/workflows/{name}/plan", post(plan_workflow))
348            .with_state(state)
349    }
350
351    fn plan_request(name: &str, auth: Option<&str>, body: JsonValue) -> Request<Body> {
352        let mut builder = Request::builder()
353            .method("POST")
354            .uri(format!("/api/v1/workflows/{name}/plan"))
355            .header("content-type", "application/json");
356        if let Some(header) = auth {
357            builder = builder.header("authorization", header);
358        }
359        builder.body(Body::from(body.to_string())).unwrap()
360    }
361
362    async fn body_json(response: Response) -> JsonValue {
363        let bytes = response.into_body().collect().await.unwrap().to_bytes();
364        from_slice(&bytes).unwrap()
365    }
366
367    #[tokio::test]
368    async fn plan_returns_steps_for_registered_workflow() {
369        let state = test_state();
370        let auth = create_user_auth_header(&state, "testuser", false).await;
371        let response = app(state)
372            .oneshot(plan_request("planned", Some(&auth), json!({})))
373            .await
374            .unwrap();
375
376        assert_eq!(response.status(), StatusCode::OK);
377        let body = body_json(response).await;
378        assert_eq!(body["data"]["workflow"], "planned");
379        let steps = body["data"]["steps"].as_array().unwrap();
380        assert_eq!(steps.len(), 3);
381        assert_eq!(steps[0]["name"], "build");
382        assert_eq!(steps[0]["kind"], "shell");
383        assert_eq!(steps[1]["name"], "test");
384        assert_eq!(steps[2]["name"], "lint");
385        assert_eq!(body["data"]["truncated"], false);
386    }
387
388    #[tokio::test]
389    async fn plan_marks_parallel_group() {
390        let state = test_state();
391        let auth = create_user_auth_header(&state, "testuser", false).await;
392        let response = app(state)
393            .oneshot(plan_request("planned", Some(&auth), json!({})))
394            .await
395            .unwrap();
396
397        let body = body_json(response).await;
398        let steps = body["data"]["steps"].as_array().unwrap();
399        assert!(steps[0].get("parallel_group").is_none());
400        assert_eq!(steps[1]["parallel_group"], "parallel-1");
401        assert_eq!(steps[2]["parallel_group"], "parallel-1");
402    }
403
404    #[tokio::test]
405    async fn plan_evaluates_condition_from_payload() {
406        let state = test_state();
407        let auth = create_user_auth_header(&state, "testuser", false).await;
408        let router = app(state);
409
410        let response = router
411            .clone()
412            .oneshot(plan_request(
413                "conditional",
414                Some(&auth),
415                json!({"payload": {"env": "prod"}}),
416            ))
417            .await
418            .unwrap();
419        let body = body_json(response).await;
420        let step = &body["data"]["steps"][0];
421        assert_eq!(step["kind"], "shell");
422        assert_eq!(step["condition"]["state"], "evaluated");
423        assert_eq!(step["condition"]["expression"], "production run");
424        assert_eq!(step["condition"]["value"], true);
425
426        let response = router
427            .oneshot(plan_request(
428                "conditional",
429                Some(&auth),
430                json!({"payload": {"env": "dev"}}),
431            ))
432            .await
433            .unwrap();
434        let body = body_json(response).await;
435        let step = &body["data"]["steps"][0];
436        assert_eq!(step["kind"], "skip");
437        assert_eq!(step["condition"]["state"], "skipped");
438        assert_eq!(step["condition"]["reason"], "not prod");
439    }
440
441    #[tokio::test]
442    async fn plan_unknown_workflow_returns_404() {
443        let state = test_state();
444        let auth = create_user_auth_header(&state, "testuser", false).await;
445        let response = app(state)
446            .oneshot(plan_request("nonexistent", Some(&auth), json!({})))
447            .await
448            .unwrap();
449
450        assert_eq!(response.status(), StatusCode::NOT_FOUND);
451    }
452
453    #[tokio::test]
454    async fn plan_rejects_zero_and_excessive_max_depth() {
455        let state = test_state();
456        let auth = create_user_auth_header(&state, "testuser", false).await;
457        let router = app(state);
458
459        let response = router
460            .clone()
461            .oneshot(plan_request(
462                "planned",
463                Some(&auth),
464                json!({"max_depth": 0}),
465            ))
466            .await
467            .unwrap();
468        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
469
470        let response = router
471            .oneshot(plan_request(
472                "planned",
473                Some(&auth),
474                json!({"max_depth": 11}),
475            ))
476            .await
477            .unwrap();
478        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
479    }
480
481    #[tokio::test]
482    async fn plan_rejects_non_object_payload() {
483        let state = test_state();
484        let auth = create_user_auth_header(&state, "testuser", false).await;
485        let response = app(state)
486            .oneshot(plan_request(
487                "planned",
488                Some(&auth),
489                json!({"payload": [1, 2, 3]}),
490            ))
491            .await
492            .unwrap();
493
494        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
495    }
496
497    #[tokio::test]
498    async fn plan_requires_authentication() {
499        let state = test_state();
500        let response = app(state)
501            .oneshot(plan_request("planned", None, json!({})))
502            .await
503            .unwrap();
504
505        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
506    }
507
508    #[tokio::test]
509    async fn plan_does_not_create_a_run() {
510        let state = test_state();
511        let store = state.store.clone();
512        let auth = create_user_auth_header(&state, "testuser", false).await;
513        let response = app(state)
514            .oneshot(plan_request("planned", Some(&auth), json!({})))
515            .await
516            .unwrap();
517        assert_eq!(response.status(), StatusCode::OK);
518
519        let runs = store.list_runs(RunFilter::default(), 1, 10).await.unwrap();
520        assert!(runs.items.is_empty());
521    }
522}