use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::info;
use uuid::Uuid;
use dk_engine::repo::Engine;
use crate::changeset::scope_command_to_changeset;
use crate::executor::{Executor, StepOutput, StepStatus};
use crate::findings::{Finding, Suggestion};
use crate::steps::{agent_review, command, human_approve, semantic};
use crate::workflow::types::{Stage, Step, StepType, Workflow};
#[derive(Debug, Clone)]
pub struct StepResult {
pub stage_name: String,
pub step_name: String,
pub status: StepStatus,
pub output: String,
pub required: bool,
pub findings: Vec<Finding>,
pub suggestions: Vec<Suggestion>,
}
pub async fn run_workflow(
workflow: &Workflow,
executor: &dyn Executor,
work_dir: &Path,
changeset_files: &[String],
env: &HashMap<String, String>,
tx: &mpsc::Sender<StepResult>,
engine: Option<&Arc<Engine>>,
repo_id: Option<Uuid>,
changeset_id: Option<Uuid>,
) -> bool {
let mut all_passed = true;
for stage in &workflow.stages {
info!(stage = %stage.name, parallel = stage.parallel, "running stage");
let results = if stage.parallel {
run_stage_parallel(stage, executor, work_dir, changeset_files, env, engine, repo_id, changeset_id)
.await
} else {
run_stage_sequential(stage, executor, work_dir, changeset_files, env, engine, repo_id, changeset_id)
.await
};
for result in results {
if result.status != StepStatus::Pass && result.required {
all_passed = false;
}
let _ = tx.send(result).await;
}
}
all_passed
}
async fn run_stage_parallel(
stage: &Stage,
executor: &dyn Executor,
work_dir: &Path,
changeset_files: &[String],
env: &HashMap<String, String>,
engine: Option<&Arc<Engine>>,
repo_id: Option<Uuid>,
changeset_id: Option<Uuid>,
) -> Vec<StepResult> {
let mut futures = Vec::new();
for step in &stage.steps {
futures.push(run_single_step(
&stage.name,
step,
executor,
work_dir,
changeset_files,
env,
engine,
repo_id,
changeset_id,
));
}
futures::future::join_all(futures).await
}
async fn run_stage_sequential(
stage: &Stage,
executor: &dyn Executor,
work_dir: &Path,
changeset_files: &[String],
env: &HashMap<String, String>,
engine: Option<&Arc<Engine>>,
repo_id: Option<Uuid>,
changeset_id: Option<Uuid>,
) -> Vec<StepResult> {
let mut results = Vec::new();
for step in &stage.steps {
let result = run_single_step(
&stage.name,
step,
executor,
work_dir,
changeset_files,
env,
engine,
repo_id,
changeset_id,
)
.await;
let failed_required = step.required && result.status != StepStatus::Pass;
results.push(result);
if failed_required {
tracing::warn!(
stage = %stage.name,
step = %step.name,
"required step failed — aborting remaining steps in sequential stage"
);
break;
}
}
results
}
async fn run_single_step(
stage_name: &str,
step: &Step,
executor: &dyn Executor,
work_dir: &Path,
changeset_files: &[String],
env: &HashMap<String, String>,
engine: Option<&Arc<Engine>>,
repo_id: Option<Uuid>,
changeset_id: Option<Uuid>,
) -> StepResult {
info!(step = %step.name, "running step");
match &step.step_type {
StepType::Command { run } => {
let cmd = if step.changeset_aware {
let local_files: Vec<String> = if let Some(sub) = &step.work_dir {
let prefix = format!("{}/", sub.display());
changeset_files
.iter()
.filter_map(|f| f.strip_prefix(&prefix).map(|s| s.to_string()))
.collect()
} else {
changeset_files.to_vec()
};
scope_command_to_changeset(run, &local_files)
.unwrap_or_else(|| run.clone())
} else {
run.clone()
};
let step_work_dir = match &step.work_dir {
Some(sub) => work_dir.join(sub),
None => work_dir.to_path_buf(),
};
let output =
match command::run_command_step(executor, &cmd, &step_work_dir, step.timeout, env).await {
Ok(out) => out,
Err(e) => StepOutput {
status: StepStatus::Fail,
stdout: String::new(),
stderr: e.to_string(),
duration: std::time::Duration::ZERO,
},
};
let combined_output = if output.stderr.is_empty() {
output.stdout
} else {
format!("{}{}", output.stdout, output.stderr)
};
StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: combined_output,
required: step.required,
findings: Vec::new(),
suggestions: Vec::new(),
}
}
StepType::Semantic { checks } => {
if let (Some(eng), Some(rid)) = (engine, repo_id) {
let (output, findings, suggestions) = semantic::run_semantic_step(
eng,
rid,
changeset_files,
work_dir,
checks,
)
.await;
let combined_output = if output.stderr.is_empty() {
output.stdout
} else {
format!("{}{}", output.stdout, output.stderr)
};
StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: combined_output,
required: step.required,
findings,
suggestions,
}
} else {
let output = semantic::run_semantic_step_simple(checks).await;
let combined_output = if output.stderr.is_empty() {
output.stdout
} else {
format!("{}{}", output.stdout, output.stderr)
};
StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: combined_output,
required: step.required,
findings: Vec::new(),
suggestions: Vec::new(),
}
}
}
StepType::AgentReview { prompt } => {
let provider = agent_review::claude::ClaudeReviewProvider::from_env();
if let Some(provider) = provider {
let mut diff = String::new();
let mut files = Vec::new();
for path in changeset_files {
let full_path = work_dir.join(path);
if let Ok(content) = tokio::fs::read_to_string(&full_path).await {
diff.push_str(&format!("--- {path}\n+++ {path}\n{content}\n"));
files.push(agent_review::provider::FileContext {
path: path.clone(),
content,
});
}
}
let (output, findings, suggestions) =
agent_review::run_agent_review_step_with_provider(
&provider, &diff, files, prompt,
)
.await;
return StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: if output.stderr.is_empty() {
output.stdout
} else {
format!("{}{}", output.stdout, output.stderr)
},
required: step.required,
findings,
suggestions,
};
}
let output = agent_review::run_agent_review_step(prompt).await;
StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: if output.stderr.is_empty() {
output.stdout
} else {
format!("{}{}", output.stdout, output.stderr)
},
required: step.required,
findings: Vec::new(),
suggestions: Vec::new(),
}
}
StepType::HumanApprove => {
if let (Some(eng), Some(cid)) = (engine, changeset_id) {
let (output, findings) = human_approve::run_human_approve_step_with_engine(
eng, cid, Some(step.timeout),
).await;
return StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: if output.stderr.is_empty() { output.stdout } else { format!("{}{}", output.stdout, output.stderr) },
required: step.required,
findings,
suggestions: Vec::new(),
};
}
let output = human_approve::run_human_approve_step().await;
StepResult {
stage_name: stage_name.to_string(),
step_name: step.name.clone(),
status: output.status,
output: if output.stderr.is_empty() { output.stdout } else { format!("{}{}", output.stdout, output.stderr) },
required: step.required,
findings: Vec::new(),
suggestions: Vec::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executor::process::ProcessExecutor;
use crate::workflow::types::*;
use std::time::Duration;
#[tokio::test]
async fn test_run_workflow_passes() {
let wf = Workflow {
name: "test".into(),
timeout: Duration::from_secs(30),
stages: vec![Stage {
name: "checks".into(),
parallel: false,
steps: vec![Step {
name: "echo-test".into(),
step_type: StepType::Command {
run: "echo hello".into(),
},
timeout: Duration::from_secs(5),
required: true,
changeset_aware: false,
work_dir: None,
}],
}],
allowed_commands: vec![],
};
let exec = ProcessExecutor::new();
let (tx, mut rx) = mpsc::channel(32);
let dir = std::env::temp_dir();
let passed =
run_workflow(&wf, &exec, &dir, &[], &HashMap::new(), &tx, None, None, None).await;
drop(tx);
assert!(passed);
let result = rx.recv().await.unwrap();
assert_eq!(result.status, StepStatus::Pass);
}
#[tokio::test]
async fn test_failing_required_step() {
let wf = Workflow {
name: "test".into(),
timeout: Duration::from_secs(30),
stages: vec![Stage {
name: "checks".into(),
parallel: false,
steps: vec![Step {
name: "disallowed".into(),
step_type: StepType::Command {
run: "false_cmd_not_in_allowlist".into(),
},
timeout: Duration::from_secs(5),
required: true,
changeset_aware: false,
work_dir: None,
}],
}],
allowed_commands: vec![],
};
let exec = ProcessExecutor::new();
let (tx, _rx) = mpsc::channel(32);
let dir = std::env::temp_dir();
let passed =
run_workflow(&wf, &exec, &dir, &[], &HashMap::new(), &tx, None, None, None).await;
drop(tx);
assert!(!passed);
}
#[tokio::test]
async fn test_parallel_stage() {
let wf = Workflow {
name: "test".into(),
timeout: Duration::from_secs(30),
stages: vec![Stage {
name: "parallel-checks".into(),
parallel: true,
steps: vec![
Step {
name: "echo-a".into(),
step_type: StepType::Command {
run: "echo a".into(),
},
timeout: Duration::from_secs(5),
required: true,
changeset_aware: false,
work_dir: None,
},
Step {
name: "echo-b".into(),
step_type: StepType::Command {
run: "echo b".into(),
},
timeout: Duration::from_secs(5),
required: true,
changeset_aware: false,
work_dir: None,
},
],
}],
allowed_commands: vec![],
};
let exec = ProcessExecutor::new();
let (tx, mut rx) = mpsc::channel(32);
let dir = std::env::temp_dir();
let passed =
run_workflow(&wf, &exec, &dir, &[], &HashMap::new(), &tx, None, None, None).await;
drop(tx);
assert!(passed);
let mut results = Vec::new();
while let Some(r) = rx.recv().await {
results.push(r);
}
assert_eq!(results.len(), 2);
}
}