use clap::Parser as ClapParser;
use std::path::Path;
use std::pin::Pin;
use std::process;
use std::sync::{Arc, Mutex};
use o7::builtins;
use o7::engine::engine::ExecutionEngine;
use o7::engine::types::{
ExecutionEvent, HarnessDispatchFn, HarnessExecContext, OnEventCallback, OnSaveCallback,
};
use o7::parser::ast::{ParseResult, WorkflowDecl};
use o7::state::harness_events::HarnessEventCollector;
use o7::state::types::HarnessEvent;
#[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: Option<String>,
workflow: Option<String>,
#[arg(long)]
project: Option<String>,
},
Tui {
file: Option<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>>>,
harness_events: Arc<Mutex<HarnessEventCollector>>,
) -> 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, context: HarnessExecContext| {
let root = project_root.clone();
let block = exec_block.clone();
let config = config.clone();
let run_id = run_id.clone();
let harness_events = harness_events.clone();
Box::pin(async move {
let ctx = o7::harness::RunContext {
run_id,
project_root: root.clone(),
};
let harness_context = o7::harness::HarnessExecutionContext {
run_id: context.run_id,
step_path: context.step_path,
exec_ordinal: context.exec_ordinal,
on_harness_event: Some(Arc::new(move |event: HarnessEvent| {
if let Ok(mut collector) = harness_events.lock() {
collector.record(event);
}
})),
};
o7::harness::dispatch_exec(&config, &root, &block, Some(&ctx), Some(&harness_context))
.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 resolve_default_file() -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let candidate = cwd.join("o7.md");
if candidate.exists() {
candidate.to_string_lossy().to_string()
} else {
eprintln!(
"Error: no file argument given and no o7.md found in {}",
cwd.display()
);
eprintln!("Usage: o7 run <file> or cd myproject && o7 run");
process::exit(1);
}
}
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,
harness_events: Arc<Mutex<HarnessEventCollector>>,
) -> OnSaveCallback {
Box::new(move |state| {
let harness_event_log = harness_events
.lock()
.ok()
.map(|collector| collector.snapshot());
let persisted = o7::state::adapter::to_persisted_state(state, harness_event_log);
if let Err(e) = o7::state::persistence::save_state(&persisted, &project_root) {
eprintln!("[o7] Warning: failed to save state: {}", e);
}
})
}
fn summarize_harness_event(event: &HarnessEvent) -> String {
if let Some(parsed) = &event.parsed {
if let Some(obj) = parsed.as_object() {
if let Some(kind) = obj.get("type").and_then(|value| value.as_str()) {
return kind.to_string();
}
}
parsed.to_string()
} else {
event.raw.clone()
}
}
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 harness_events = Arc::new(Mutex::new(HarnessEventCollector::new(-1, Vec::new())));
let dispatch = match make_dispatch_fn(
project_root.clone(),
run_id_holder.clone(),
harness_events.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 harness_events_for_engine = harness_events.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));
}
}
}
if let Ok(mut collector) = harness_events_for_engine.lock() {
collector.observe_engine_event(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(), harness_events.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());
}
if let Some(harness_event_log) = &state.harness_event_log
&& !harness_event_log.is_empty()
{
println!("Harness events: {}", harness_event_log.len());
for event in harness_event_log
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
let path = event
.step_path
.as_ref()
.map(|parts| parts.join(" > "))
.unwrap_or_else(|| "(unknown)".to_string());
println!(
" [{}/{}] #{} {} {}",
serde_json::to_string(&event.stream)
.unwrap_or_else(|_| "\"stdout\"".to_string())
.trim_matches('"'),
serde_json::to_string(&event.kind)
.unwrap_or_else(|_| "\"text\"".to_string())
.trim_matches('"'),
event.sequence,
path,
summarize_harness_event(event),
);
}
}
}
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 harness_events = Arc::new(Mutex::new(HarnessEventCollector::new(
persisted.current_boundary_index,
persisted.harness_event_log.clone().unwrap_or_default(),
)));
let dispatch = match make_dispatch_fn(
project_root.clone(),
run_id_holder.clone(),
harness_events.clone(),
) {
Ok(d) => d,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
let harness_events_for_engine = harness_events.clone();
let on_event: OnEventCallback = Box::new(move |event| {
if let Ok(mut collector) = harness_events_for_engine.lock() {
collector.observe_engine_event(event);
}
print_event(event);
});
let on_save = make_on_save(project_root.clone(), harness_events.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 = o7::state::adapter::truncate_to_last_boundary(&persisted, boundary);
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,
}) => {
let file = file.unwrap_or_else(resolve_default_file);
cmd_run(&file, workflow.as_deref(), project.as_deref()).await;
}
Some(Commands::Tui {
file,
workflow,
project,
}) => {
let file = file.unwrap_or_else(resolve_default_file);
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);
}
}
}