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::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 };
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}