use clap::Parser as ClapParser;
use std::path::Path;
use std::pin::Pin;
use std::process;
use std::sync::Arc;
use o7::builtins;
use o7::engine::engine::ExecutionEngine;
use o7::engine::types::{ExecutionEvent, HarnessDispatchFn, OnEventCallback, OnSaveCallback};
use o7::parser::ast::{ParseResult, WorkflowDecl};
#[derive(ClapParser)]
#[command(name = "o7", version, about = "O7 workflow DSL runner")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(trailing_var_arg = true)]
file: Option<Vec<String>>,
}
#[derive(clap::Subcommand)]
enum Commands {
Run {
file: String,
workflow: Option<String>,
#[arg(long)]
project: Option<String>,
},
Tui {
file: String,
workflow: Option<String>,
#[arg(long)]
project: Option<String>,
},
Parse {
file: String,
},
Lint {
file: String,
},
Inspect {
run_id: Option<String>,
#[arg(long)]
project: Option<String>,
},
Resume {
run_id: String,
#[arg(long)]
project: Option<String>,
},
Reset {
run_id: String,
boundary: usize,
#[arg(long)]
project: Option<String>,
},
Wiz {
#[arg(long)]
project: Option<String>,
},
}
fn make_dispatch_fn(
project_root: String,
run_id: Arc<std::sync::RwLock<Option<String>>>,
) -> Result<HarnessDispatchFn, String> {
let config = match o7::harness::config::load_harness_config(&project_root) {
Ok(c) => c,
Err(_) => o7::harness::types::HarnessConfig { harness: std::collections::HashMap::new() },
};
let config = Arc::new(config);
Ok(Arc::new(move |exec_block| {
let root = project_root.clone();
let block = exec_block.clone();
let config = config.clone();
let run_id = run_id.clone();
Box::pin(async move {
let ctx = o7::harness::RunContext {
run_id,
project_root: root.clone(),
};
o7::harness::dispatch_exec(&config, &root, &block, Some(&ctx)).await
}) as Pin<Box<dyn std::future::Future<Output = Result<o7::harness::types::ExecResult, String>> + Send>>
}))
}
fn extract_run_id(event: &ExecutionEvent) -> String {
match event {
ExecutionEvent::StepStarted { runId, .. } => runId.clone(),
ExecutionEvent::StepCompleted { runId, .. } => runId.clone(),
ExecutionEvent::StepFailed { runId, .. } => runId.clone(),
ExecutionEvent::CheckEvaluated { runId, .. } => runId.clone(),
ExecutionEvent::MatchEvaluated { runId, .. } => runId.clone(),
ExecutionEvent::BranchStarted { runId, .. } => runId.clone(),
ExecutionEvent::BranchCompleted { runId, .. } => runId.clone(),
ExecutionEvent::BranchFailed { runId, .. } => runId.clone(),
ExecutionEvent::JoinStarted { runId, .. } => runId.clone(),
ExecutionEvent::RunPaused { runId, .. } => runId.clone(),
ExecutionEvent::RunCompleted { runId } => runId.clone(),
ExecutionEvent::RunFailed { runId, .. } => runId.clone(),
ExecutionEvent::SafeBoundary { runId, .. } => runId.clone(),
}
}
fn print_event(event: &ExecutionEvent) {
match event {
ExecutionEvent::StepStarted { stepPath, .. } => {
println!(">> {}", stepPath.join(" > "));
}
ExecutionEvent::StepCompleted { stepPath, .. } => {
println!("<< {}", stepPath.join(" > "));
}
ExecutionEvent::StepFailed { stepPath, error, .. } => {
println!("!! {} \u{2014} {}", stepPath.join(" > "), error);
}
ExecutionEvent::BranchStarted { .. } => {
println!("-- BranchStarted");
}
ExecutionEvent::BranchCompleted { .. } => {
println!("-- BranchCompleted");
}
ExecutionEvent::BranchFailed { branchPath, error, .. } => {
println!("-- BranchFailed {} \u{2014} {}", branchPath.join(" > "), error);
}
ExecutionEvent::JoinStarted { joinWorkflow, .. } => {
println!("-- JoinStarted {}", joinWorkflow);
}
ExecutionEvent::RunCompleted { .. } => {
println!("** RunCompleted");
}
ExecutionEvent::RunFailed { position, error, .. } => {
println!("!! RunFailed at {} \u{2014} {}", position.join(" > "), error);
}
ExecutionEvent::RunPaused { position, .. } => {
println!("** RunPaused at {}", position.join(" > "));
}
ExecutionEvent::CheckEvaluated { checkName, result, reason, .. } => {
if let Some(reason) = reason {
println!("?? {} = {} ({})", checkName, result, reason);
} else {
println!("?? {} = {}", checkName, result);
}
}
ExecutionEvent::MatchEvaluated { checkName, variant, reason, armIndex, .. } => {
let arm_str = match armIndex {
Some(idx) => format!("arm {}", idx),
None => "else".to_string(),
};
if let Some(reason) = reason {
println!("~> {} => {} [{}] ({})", checkName, variant, arm_str, reason);
} else {
println!("~> {} => {} [{}]", checkName, variant, arm_str);
}
}
ExecutionEvent::SafeBoundary { .. } => {
}
}
}
fn resolve_file(file: &str) -> String {
let path = Path::new(file);
if path.is_absolute() {
file.to_string()
} else {
std::env::current_dir()
.unwrap_or_default()
.join(path)
.to_string_lossy()
.to_string()
}
}
fn project_root_from_file(file: &str) -> String {
let path = Path::new(file);
path.parent()
.unwrap_or(Path::new("."))
.to_string_lossy()
.to_string()
}
fn project_root_from_args(workflow_file: &str, project: Option<&str>) -> String {
match project {
Some(p) => p.to_string(),
None => project_root_from_file(workflow_file),
}
}
fn parse_or_exit(file: &str) -> (Vec<WorkflowDecl>, String) {
let workflow_file = resolve_file(file);
let parse_result = match o7::parser::parse_file_path(&workflow_file) {
Ok(r) => r,
Err(e) => {
eprintln!("Error: file not found: {} ({})", workflow_file, e);
process::exit(1);
}
};
let workflows = match parse_result {
ParseResult::Ok { workflows } => workflows,
ParseResult::Err { errors } => {
for err in &errors {
eprintln!("{}:{}:{}: {}", err.file, err.line, err.column, err.message);
}
process::exit(1);
}
};
(workflows, workflow_file)
}
fn parse_file_or_exit(file: &str) -> (ParseResult, String) {
let workflow_file = resolve_file(file);
let parse_result = match o7::parser::parse_file_path(&workflow_file) {
Ok(r) => r,
Err(e) => {
eprintln!("Error: file not found: {} ({})", workflow_file, e);
process::exit(1);
}
};
(parse_result, workflow_file)
}
fn make_on_save(project_root: String) -> OnSaveCallback {
Box::new(move |state| {
let persisted = o7::state::adapter::to_persisted_state(state);
if let Err(e) = o7::state::persistence::save_state(&persisted, &project_root) {
eprintln!("[o7] Warning: failed to save state: {}", e);
}
})
}
fn cmd_parse(file: &str) {
let (parse_result, _) = parse_file_or_exit(file);
match parse_result {
ParseResult::Ok { workflows } => {
let names: Vec<&str> = workflows.iter().map(|w| w.name.as_str()).collect();
println!("OK \u{2014} {} workflow(s): {}", names.len(), names.join(", "));
}
ParseResult::Err { errors } => {
eprintln!("Parse errors:");
for err in &errors {
eprintln!(" {}:{}:{}: {}", err.file, err.line, err.column, err.message);
}
process::exit(1);
}
}
}
fn cmd_lint(file: &str) {
let (parse_result, _) = parse_file_or_exit(file);
match parse_result {
ParseResult::Ok { .. } => {
println!("No errors found.");
}
ParseResult::Err { errors } => {
for err in &errors {
println!("{}:{}:{}: {}", err.file, err.line, err.column, err.message);
}
process::exit(1);
}
}
}
fn has_unanswered_qa_files(project_root: &str, run_id: &str) -> bool {
let qa_dir = std::path::Path::new(project_root)
.join(".7")
.join("runs")
.join(run_id)
.join("qa");
let entries = match std::fs::read_dir(&qa_dir) {
Ok(e) => e,
Err(_) => return false,
};
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
files.iter()
.filter(|f| {
f.len() == 14
&& f.starts_with("q-out-")
&& f.ends_with(".json")
&& f[6..9].chars().all(|c| c.is_ascii_digit())
})
.any(|f| {
let seq = &f[6..9];
!files.iter().any(|a| a == &format!("q-answers-{}.json", seq))
})
}
async fn cmd_run(file: &str, workflow: Option<&str>, project: Option<&str>) {
let (resolved_file, resolved_root) = if file.starts_with('@') {
match builtins::resolve_workflow_path(file) {
Ok(pair) => pair,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
} else {
let abs = resolve_file(file);
let root = project_root_from_args(&abs, project);
(std::path::PathBuf::from(&abs), std::path::PathBuf::from(root))
};
let file = resolved_file.to_string_lossy().to_string();
let project = project.or_else(|| Some(resolved_root.to_str().unwrap_or(".")));
let (workflows, workflow_file) = parse_or_exit(&file);
let project_root = project_root_from_args(&workflow_file, project);
let root_workflow = workflow.unwrap_or("main");
let run_id_holder: Arc<std::sync::RwLock<Option<String>>> = Arc::new(std::sync::RwLock::new(None));
let dispatch = match make_dispatch_fn(project_root.clone(), run_id_holder.clone()) {
Ok(d) => d,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
let run_id_for_event = run_id_holder.clone();
let project_root_for_event = project_root.clone();
let on_event: OnEventCallback = Box::new(move |event| {
{
if let Ok(mut guard) = run_id_for_event.write() {
if guard.is_none() {
*guard = Some(extract_run_id(event));
}
}
}
print_event(event);
if matches!(event, ExecutionEvent::StepCompleted { .. }) {
if let Ok(guard) = run_id_for_event.read() {
if let Some(ref run_id) = *guard {
if has_unanswered_qa_files(&project_root_for_event, run_id) {
eprintln!("\nError: This workflow uses interactive Q&A but no TUI is running to answer questions.");
eprintln!("Use `o7 tui` instead of `o7 run`.");
process::exit(1);
}
}
}
}
});
let on_save = make_on_save(project_root.clone());
let mut engine = ExecutionEngine::new(
workflows,
dispatch,
Some(on_event),
Some(on_save),
);
let (_run_id, events) = engine.start_run(root_workflow).await;
let failed = events.iter().any(|e| matches!(e, ExecutionEvent::RunFailed { .. }));
if failed {
process::exit(1);
}
}
async fn cmd_tui(file: &str, workflow: Option<&str>, project: Option<&str>) {
let (resolved_file, resolved_root) = if file.starts_with('@') {
match builtins::resolve_workflow_path(file) {
Ok(pair) => pair,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
} else {
let abs = resolve_file(file);
let root = project_root_from_args(&abs, project);
(std::path::PathBuf::from(&abs), std::path::PathBuf::from(root))
};
let file = resolved_file.to_string_lossy().to_string();
let project = project.or_else(|| Some(resolved_root.to_str().unwrap_or(".")));
let (workflows, workflow_file) = parse_or_exit(&file);
let project_root = project_root_from_args(&workflow_file, project);
let root_workflow = workflow.unwrap_or("main");
if let Err(e) = o7::tui::run_tui(workflows, &project_root, root_workflow).await {
eprintln!("TUI error: {}", e);
process::exit(1);
}
}
fn cmd_inspect(run_id: Option<&str>, project: Option<&str>) {
let project_root = project.unwrap_or(".").to_string();
match run_id {
None => {
match o7::state::persistence::list_runs(&project_root) {
Ok(runs) => {
if runs.is_empty() {
println!("No runs found.");
return;
}
println!("Runs:");
for run in &runs {
println!(
" {} {} {} {}",
run.run_id,
format!("{:<10}", run.status),
run.root_workflow,
run.timestamp,
);
}
}
Err(e) => {
eprintln!("Error listing runs: {}", e);
process::exit(1);
}
}
}
Some(rid) => {
match o7::state::persistence::load_state(rid, &project_root) {
Ok(state) => {
println!("Run: {}", state.run_id);
println!("Status: {}", state.status);
println!("Workflow: {}", state.root_workflow);
println!("Timestamp: {}", state.timestamp);
if !state.call_stack.is_empty() {
let stack_str: Vec<String> = state
.call_stack
.iter()
.map(|c| format!("{}[{}]", c.workflow, c.step_index))
.collect();
println!("Call stack: {}", stack_str.join(" > "));
}
if !state.steps.is_empty() {
println!("Steps:");
for (path, step) in &state.steps {
let marker = match step.status {
o7::state::types::StepStatusPersisted::Completed => "+",
o7::state::types::StepStatusPersisted::Failed => "!",
_ => "-",
};
let kind_str = serde_json::to_string(&step.kind)
.unwrap_or_else(|_| "?".to_string())
.trim_matches('"')
.to_string();
println!(" [{}] {} ({})", marker, path, kind_str);
}
}
if !state.safe_boundaries.is_empty() {
println!("Safe boundaries: {}", state.safe_boundaries.len());
}
}
Err(e) => {
eprintln!("Run not found: {}", rid);
eprintln!("{}", e);
process::exit(1);
}
}
}
}
}
async fn cmd_resume(run_id: &str, project: Option<&str>) {
let project_root = project.unwrap_or(".").to_string();
let persisted = match o7::state::persistence::load_state(run_id, &project_root) {
Ok(s) => s,
Err(e) => {
eprintln!("Run not found: {}", run_id);
eprintln!("{}", e);
process::exit(1);
}
};
if persisted.status != o7::state::types::RunStatus::Paused {
eprintln!("Run {} is not paused (status: {})", run_id, persisted.status);
process::exit(1);
}
let engine_state = match o7::state::adapter::to_engine_state(&persisted) {
Ok(s) => s,
Err(e) => {
eprintln!("Cannot resume run {}: {}", run_id, e);
process::exit(1);
}
};
let parse_result = match o7::parser::discover_and_parse(&project_root) {
Ok(r) => r,
Err(e) => {
eprintln!("Error discovering workflows: {}", e);
process::exit(1);
}
};
let workflows = match parse_result {
ParseResult::Ok { workflows } => workflows,
ParseResult::Err { errors } => {
for err in &errors {
eprintln!("{}:{}:{}: {}", err.file, err.line, err.column, err.message);
}
process::exit(1);
}
};
let run_id_holder: Arc<std::sync::RwLock<Option<String>>> =
Arc::new(std::sync::RwLock::new(Some(run_id.to_string())));
let dispatch = match make_dispatch_fn(project_root.clone(), run_id_holder.clone()) {
Ok(d) => d,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
let on_event: OnEventCallback = Box::new(|event| print_event(event));
let on_save = make_on_save(project_root.clone());
let mut engine = ExecutionEngine::new(
workflows,
dispatch,
Some(on_event),
Some(on_save),
);
let (_run_id, events) = engine.resume_run(engine_state).await;
let failed = events.iter().any(|e| matches!(e, ExecutionEvent::RunFailed { .. }));
if failed {
process::exit(1);
}
}
fn cmd_reset(run_id: &str, boundary: usize, project: Option<&str>) {
let project_root = project.unwrap_or(".").to_string();
if let Err(e) = o7::state::persistence::validate_boundary(run_id, boundary, &project_root) {
eprintln!("{}", e);
process::exit(1);
}
let mut persisted = match o7::state::persistence::load_state(run_id, &project_root) {
Ok(s) => s,
Err(e) => {
eprintln!("Run not found: {}", run_id);
eprintln!("{}", e);
process::exit(1);
}
};
persisted.status = o7::state::types::RunStatus::Paused;
persisted.current_boundary_index = boundary as i64;
persisted.safe_boundaries.truncate(boundary + 1);
if let Some(ref mut event_log) = persisted.event_log {
let mut safe_boundary_count = 0;
let mut boundary_event_idx = event_log.len().saturating_sub(1);
for (i, ev) in event_log.iter().enumerate() {
if matches!(ev, ExecutionEvent::SafeBoundary { .. }) {
if safe_boundary_count == boundary {
boundary_event_idx = i;
break;
}
safe_boundary_count += 1;
}
}
event_log.truncate(boundary_event_idx + 1);
}
if let Err(e) = o7::state::persistence::save_state(&persisted, &project_root) {
eprintln!("Failed to save reset state: {}", e);
process::exit(1);
}
println!("Reset run {} to boundary {}. Status: paused.", run_id, boundary);
println!("Run 'o7 resume {}' to continue.", run_id);
}
async fn cmd_wiz(project: Option<&str>) {
let project_root = project.unwrap_or(".");
if let Err(e) = o7::wiz::run_wiz(project_root).await {
eprintln!("Wizard error: {}", e);
process::exit(1);
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Some(Commands::Run { file, workflow, project }) => {
cmd_run(&file, workflow.as_deref(), project.as_deref()).await;
}
Some(Commands::Tui { file, workflow, project }) => {
cmd_tui(&file, workflow.as_deref(), project.as_deref()).await;
}
Some(Commands::Parse { file }) => {
cmd_parse(&file);
}
Some(Commands::Lint { file }) => {
cmd_lint(&file);
}
Some(Commands::Inspect { run_id, project }) => {
cmd_inspect(run_id.as_deref(), project.as_deref());
}
Some(Commands::Resume { run_id, project }) => {
cmd_resume(&run_id, project.as_deref()).await;
}
Some(Commands::Reset { run_id, boundary, project }) => {
cmd_reset(&run_id, boundary, project.as_deref());
}
Some(Commands::Wiz { project }) => {
cmd_wiz(project.as_deref()).await;
}
None => {
if let Some(ref args) = cli.file {
if !args.is_empty() && args[0].ends_with(".7") {
let file = &args[0];
let workflow = args.get(1).map(|s| s.as_str());
cmd_run(file, workflow, None).await;
return;
}
}
use clap::CommandFactory;
Cli::command().print_help().unwrap();
println!();
println!("Builtins:");
for name in builtins::list_builtins() {
match name {
"generate" => println!(" @{} Generate a .7 workflow file interactively", name),
_ => println!(" @{}", name),
}
}
println!();
println!("Run a builtin: o7 tui @generate");
process::exit(0);
}
}
}