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, 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
22const MAX_PLAN_DEPTH: u32 = 10;
24
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
27#[derive(Debug, Default, Deserialize, Serialize)]
28pub struct PlanWorkflowRequest {
29 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
31 #[serde(default)]
32 pub payload: Option<Value>,
33 #[serde(default)]
35 pub max_depth: Option<u32>,
36 #[serde(default)]
38 pub estimate_durations: Option<bool>,
39}
40
41#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43#[derive(Debug, Serialize)]
44pub struct ConditionResponse {
45 pub state: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub expression: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub value: Option<bool>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub reason: Option<String>,
56}
57
58#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[derive(Debug, Serialize)]
61pub struct PlannedStepResponse {
62 pub name: String,
64 pub kind: String,
66 pub workflow: String,
68 pub depth: u32,
70 pub depends_on: Vec<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub condition: Option<ConditionResponse>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub parallel_group: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub estimated_duration_ms: Option<u64>,
81}
82
83#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[derive(Debug, Serialize)]
86pub struct ExecutionPlanResponse {
87 pub workflow: String,
89 pub steps: Vec<PlannedStepResponse>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub estimated_duration_ms: Option<u64>,
94 pub max_depth: u32,
96 pub truncated: bool,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub incomplete_reason: Option<String>,
101}
102
103fn 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#[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 timeout: DEFAULT_PLAN_TIMEOUT,
230 };
231
232 let plan = state
233 .engine
234 .plan_handler(&name, payload, options)
235 .await
236 .map_err(|e| match e {
237 EngineError::InvalidWorkflow(msg) => ApiError::BadRequest(msg),
238 EngineError::Store(err) => ApiError::from(err),
239 other => ApiError::Internal(other.to_string()),
240 })?;
241
242 Ok(ok(ExecutionPlanResponse::from(plan)))
243}
244
245#[cfg(test)]
246mod tests {
247 use std::sync::Arc;
248
249 use axum::Router;
250 use axum::body::Body;
251 use axum::http::{Request, StatusCode};
252 use axum::response::Response;
253 use axum::routing::post;
254 use http_body_util::BodyExt;
255 use ironflow_auth::jwt::JwtConfig;
256 use ironflow_core::providers::claude::ClaudeCodeProvider;
257 use ironflow_engine::config::{ShellConfig, StepConfig};
258 use ironflow_engine::context::WorkflowContext;
259 use ironflow_engine::engine::Engine;
260 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
261 use ironflow_engine::notify::Event;
262 use ironflow_store::memory::InMemoryStore;
263 use ironflow_store::models::RunFilter;
264 use serde_json::{Value as JsonValue, from_slice};
265 use tokio::sync::broadcast;
266 use tower::ServiceExt;
267
268 use super::*;
269 use crate::routes::test_helpers::create_user_auth_header;
270
271 struct PlannedWorkflow;
272
273 impl WorkflowHandler for PlannedWorkflow {
274 fn name(&self) -> &str {
275 "planned"
276 }
277
278 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
279 Box::pin(async move {
280 ctx.shell("build", ShellConfig::new("echo build")).await?;
281 ctx.parallel(
282 vec![
283 ("test", StepConfig::Shell(ShellConfig::new("echo test"))),
284 ("lint", StepConfig::Shell(ShellConfig::new("echo lint"))),
285 ],
286 true,
287 )
288 .await?;
289 Ok(())
290 })
291 }
292 }
293
294 #[derive(Deserialize)]
295 struct DeployInput {
296 env: String,
297 }
298
299 struct ConditionalWorkflow;
300
301 impl WorkflowHandler for ConditionalWorkflow {
302 fn name(&self) -> &str {
303 "conditional"
304 }
305
306 fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
307 Box::pin(async move {
308 if ctx
309 .when("production run", |i: &DeployInput| i.env == "prod")
310 .await?
311 {
312 ctx.shell("deploy", ShellConfig::new("echo deploy")).await?;
313 } else {
314 ctx.skip("deploy", "not prod").await?;
315 }
316 Ok(())
317 })
318 }
319 }
320
321 fn test_state() -> AppState {
322 let store = Arc::new(InMemoryStore::new());
323 let provider = Arc::new(ClaudeCodeProvider::new());
324 let mut engine = Engine::new(store.clone(), provider);
325 engine.register(PlannedWorkflow).unwrap();
326 engine.register(ConditionalWorkflow).unwrap();
327 let jwt_config = Arc::new(JwtConfig {
328 secret: "test-secret".to_string(),
329 access_token_ttl_secs: 900,
330 refresh_token_ttl_secs: 604800,
331 cookie_domain: None,
332 cookie_secure: false,
333 });
334 let (event_sender, _) = broadcast::channel::<Event>(1);
335 AppState::new(
336 store,
337 Arc::new(engine),
338 jwt_config,
339 "test-worker-token".to_string(),
340 event_sender,
341 )
342 }
343
344 fn app(state: AppState) -> Router {
345 Router::new()
346 .route("/api/v1/workflows/{name}/plan", post(plan_workflow))
347 .with_state(state)
348 }
349
350 fn plan_request(name: &str, auth: Option<&str>, body: JsonValue) -> Request<Body> {
351 let mut builder = Request::builder()
352 .method("POST")
353 .uri(format!("/api/v1/workflows/{name}/plan"))
354 .header("content-type", "application/json");
355 if let Some(header) = auth {
356 builder = builder.header("authorization", header);
357 }
358 builder.body(Body::from(body.to_string())).unwrap()
359 }
360
361 async fn body_json(response: Response) -> JsonValue {
362 let bytes = response.into_body().collect().await.unwrap().to_bytes();
363 from_slice(&bytes).unwrap()
364 }
365
366 #[tokio::test]
367 async fn plan_returns_steps_for_registered_workflow() {
368 let state = test_state();
369 let auth = create_user_auth_header(&state, "testuser", false).await;
370 let response = app(state)
371 .oneshot(plan_request("planned", Some(&auth), json!({})))
372 .await
373 .unwrap();
374
375 assert_eq!(response.status(), StatusCode::OK);
376 let body = body_json(response).await;
377 assert_eq!(body["data"]["workflow"], "planned");
378 let steps = body["data"]["steps"].as_array().unwrap();
379 assert_eq!(steps.len(), 3);
380 assert_eq!(steps[0]["name"], "build");
381 assert_eq!(steps[0]["kind"], "shell");
382 assert_eq!(steps[1]["name"], "test");
383 assert_eq!(steps[2]["name"], "lint");
384 assert_eq!(body["data"]["truncated"], false);
385 }
386
387 #[tokio::test]
388 async fn plan_marks_parallel_group() {
389 let state = test_state();
390 let auth = create_user_auth_header(&state, "testuser", false).await;
391 let response = app(state)
392 .oneshot(plan_request("planned", Some(&auth), json!({})))
393 .await
394 .unwrap();
395
396 let body = body_json(response).await;
397 let steps = body["data"]["steps"].as_array().unwrap();
398 assert!(steps[0].get("parallel_group").is_none());
399 assert_eq!(steps[1]["parallel_group"], "parallel-1");
400 assert_eq!(steps[2]["parallel_group"], "parallel-1");
401 }
402
403 #[tokio::test]
404 async fn plan_evaluates_condition_from_payload() {
405 let state = test_state();
406 let auth = create_user_auth_header(&state, "testuser", false).await;
407 let router = app(state);
408
409 let response = router
410 .clone()
411 .oneshot(plan_request(
412 "conditional",
413 Some(&auth),
414 json!({"payload": {"env": "prod"}}),
415 ))
416 .await
417 .unwrap();
418 let body = body_json(response).await;
419 let step = &body["data"]["steps"][0];
420 assert_eq!(step["kind"], "shell");
421 assert_eq!(step["condition"]["state"], "evaluated");
422 assert_eq!(step["condition"]["expression"], "production run");
423 assert_eq!(step["condition"]["value"], true);
424
425 let response = router
426 .oneshot(plan_request(
427 "conditional",
428 Some(&auth),
429 json!({"payload": {"env": "dev"}}),
430 ))
431 .await
432 .unwrap();
433 let body = body_json(response).await;
434 let step = &body["data"]["steps"][0];
435 assert_eq!(step["kind"], "skip");
436 assert_eq!(step["condition"]["state"], "skipped");
437 assert_eq!(step["condition"]["reason"], "not prod");
438 }
439
440 #[tokio::test]
441 async fn plan_unknown_workflow_returns_404() {
442 let state = test_state();
443 let auth = create_user_auth_header(&state, "testuser", false).await;
444 let response = app(state)
445 .oneshot(plan_request("nonexistent", Some(&auth), json!({})))
446 .await
447 .unwrap();
448
449 assert_eq!(response.status(), StatusCode::NOT_FOUND);
450 }
451
452 #[tokio::test]
453 async fn plan_rejects_zero_and_excessive_max_depth() {
454 let state = test_state();
455 let auth = create_user_auth_header(&state, "testuser", false).await;
456 let router = app(state);
457
458 let response = router
459 .clone()
460 .oneshot(plan_request(
461 "planned",
462 Some(&auth),
463 json!({"max_depth": 0}),
464 ))
465 .await
466 .unwrap();
467 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
468
469 let response = router
470 .oneshot(plan_request(
471 "planned",
472 Some(&auth),
473 json!({"max_depth": 11}),
474 ))
475 .await
476 .unwrap();
477 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
478 }
479
480 #[tokio::test]
481 async fn plan_rejects_non_object_payload() {
482 let state = test_state();
483 let auth = create_user_auth_header(&state, "testuser", false).await;
484 let response = app(state)
485 .oneshot(plan_request(
486 "planned",
487 Some(&auth),
488 json!({"payload": [1, 2, 3]}),
489 ))
490 .await
491 .unwrap();
492
493 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
494 }
495
496 #[tokio::test]
497 async fn plan_requires_authentication() {
498 let state = test_state();
499 let response = app(state)
500 .oneshot(plan_request("planned", None, json!({})))
501 .await
502 .unwrap();
503
504 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
505 }
506
507 #[tokio::test]
508 async fn plan_does_not_create_a_run() {
509 let state = test_state();
510 let store = state.store.clone();
511 let auth = create_user_auth_header(&state, "testuser", false).await;
512 let response = app(state)
513 .oneshot(plan_request("planned", Some(&auth), json!({})))
514 .await
515 .unwrap();
516 assert_eq!(response.status(), StatusCode::OK);
517
518 let runs = store.list_runs(RunFilter::default(), 1, 10).await.unwrap();
519 assert!(runs.items.is_empty());
520 }
521}