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::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#[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}