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