Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 303 additions & 1 deletion src/automation/mod.rs

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,10 @@ pub enum Commands {
/// Print resolved steps without executing
#[arg(long)]
dry_run: bool,

/// Resolve prompt steps as API-direct steps (dry-run planning only)
#[arg(long)]
direct: bool,
},

/// Inspect SDLC run history
Expand Down Expand Up @@ -576,3 +580,18 @@ pub enum RemoteSubcommand {
/// Show registered remotes and their reachability
Status,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn run_accepts_direct_flag() {
let cli = Cli::try_parse_from(["tt", "run", "verify", "--direct"]).unwrap();

match cli.command {
Commands::Run { direct, .. } => assert!(direct),
_ => panic!("expected run command"),
}
}
}
1 change: 1 addition & 0 deletions src/cli/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ fn suggest_workflow_permissions(
let options = ExecuteOptions {
strict: false,
force_open_commands: false,
direct: false,
command_policy: global.permissions.clone(),
retry_policy: None,
origin: ExecutionOrigin::Run,
Expand Down
188 changes: 159 additions & 29 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,32 @@ use crate::{budget, budget::BudgetGuardOutcome};
use comfy_table::{Table, presets::UTF8_BORDERS_ONLY};
use serde::Serialize;

pub fn run(
workflow: Option<&str>,
resume: Option<&str>,
list: bool,
agent: Option<&str>,
json: bool,
strict: bool,
dry_run: bool,
) -> Result<()> {
pub struct RunRequest<'a> {
pub workflow: Option<&'a str>,
pub resume: Option<&'a str>,
pub list: bool,
pub agent: Option<&'a str>,
pub json: bool,
pub strict: bool,
pub dry_run: bool,
pub direct: bool,
}

pub fn run(request: RunRequest<'_>) -> Result<()> {
let cwd = std::env::current_dir()?;
let (config, config_path) = TuttiConfig::load(&cwd)?;
config.validate()?;

if list {
print_workflow_list(&config, json)?;
if request.list {
print_workflow_list(&config, request.json)?;
return Ok(());
}

let project_root = config_path.parent().ok_or_else(|| {
TuttiError::ConfigValidation("could not determine workspace root".to_string())
})?;

let resume_context = if let Some(run_id) = resume {
let resume_context = if let Some(run_id) = request.resume {
load_resume_context(project_root, run_id)?
} else {
None
Expand All @@ -41,22 +44,22 @@ pub fn run(
let workflow_name = if let Some(ctx) = resume_context.as_ref() {
ctx.workflow_name.as_str()
} else {
workflow.ok_or_else(|| {
request.workflow.ok_or_else(|| {
TuttiError::ConfigValidation(
"workflow name is required unless --list or --resume is set".to_string(),
)
})?
};

let effective_agent = agent.or_else(|| {
let effective_agent = request.agent.or_else(|| {
resume_context
.as_ref()
.and_then(|r| r.agent_scope.as_deref())
});
let budget_outcome = budget::enforce_pre_exec(&config, project_root, "run", effective_agent)?;
print_budget_warnings(&budget_outcome);

let effective_strict = strict || resume_context.as_ref().is_some_and(|r| r.strict);
let effective_strict = request.strict || resume_context.as_ref().is_some_and(|r| r.strict);
let global = GlobalConfig::load().ok();
let command_policy = global.as_ref().and_then(|g| g.permissions.clone());
let retry_policy = global
Expand All @@ -66,6 +69,7 @@ pub fn run(
let options = ExecuteOptions {
strict: effective_strict,
force_open_commands: false,
direct: request.direct,
command_policy,
retry_policy,
origin: ExecutionOrigin::Run,
Expand Down Expand Up @@ -113,23 +117,24 @@ pub fn run(
}
}

if dry_run {
if request.dry_run {
// Validate artifact_glob dependencies at dry-run time
for (idx, step) in resolved.steps.iter().enumerate() {
if let crate::automation::ResolvedStep::Prompt {
artifact_glob: Some(glob_pat),
..
} = step
&& glob_pat.contains("{slug}")
&& let Err(e) = crate::automation::validate_gstack_slug_available()
{
return Err(crate::error::TuttiError::ConfigValidation(format!(
"workflow step {} uses {{slug}} in artifact_glob but {e}",
idx + 1
)));
let artifact_glob = match step {
ResolvedStep::Prompt { artifact_glob, .. }
| ResolvedStep::Direct { artifact_glob, .. } => artifact_glob.as_deref(),
_ => None,
};
if artifact_glob.is_some_and(|glob_pat| glob_pat.contains("{slug}")) {
crate::automation::validate_gstack_slug_available().map_err(|e| {
crate::error::TuttiError::ConfigValidation(format!(
"workflow step {} uses {{slug}} in artifact_glob but {e}",
idx + 1
))
})?;
}
}
if json {
if request.json {
println!(
"{}",
serde_json::to_string_pretty(&serialize_dry_run(&resolved, effective_strict))?
Expand All @@ -148,7 +153,7 @@ pub fn run(
effective_agent,
resume_context.as_ref(),
)?;
if json {
if request.json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
print_execution_result(&result);
Expand Down Expand Up @@ -261,6 +266,46 @@ fn print_dry_run(workflow: &crate::automation::ResolvedWorkflow, strict: bool) {
truncate(&summary, 80),
])
}
ResolvedStep::Direct {
provider,
model,
policy,
text,
inject_files,
artifact_glob,
artifact_name,
wait_for_idle,
wait_timeout_secs,
startup_grace_secs,
output_json,
..
} => {
let mut summary =
format!("provider:{provider} model:{model} policy:{policy} prompt:{text}");
if !inject_files.is_empty() {
summary = format!("{summary} [inject:{}]", inject_files.len());
}
if *wait_for_idle {
summary = format!(
"{summary} [wait:{}s startup:{}s]",
wait_timeout_secs, startup_grace_secs
);
}
if let (Some(glob_pat), Some(name)) = (artifact_glob, artifact_name) {
summary = format!("{summary} [artifact:{name} glob:{glob_pat}]");
}
if let Some(path) = output_json {
summary = format!("{summary} [output:{}]", path.display());
}
table.add_row(vec![
(idx + 1).to_string(),
"direct".to_string(),
"--".to_string(),
"workspace".to_string(),
"closed".to_string(),
truncate(&summary, 80),
])
}
crate::automation::ResolvedStep::Command {
run,
cwd,
Expand Down Expand Up @@ -357,6 +402,24 @@ enum DryRunStep {
#[serde(skip_serializing_if = "Option::is_none")]
artifact_name: Option<String>,
},
Direct {
index: usize,
provider: String,
model: String,
policy: String,
summary: String,
inject_files: usize,
inject_files_raw: Vec<String>,
wait_for_idle: bool,
wait_timeout_secs: u64,
startup_grace_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
artifact_glob: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
artifact_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
output_json: Option<String>,
},
Command {
index: usize,
agent: Option<String>,
Expand Down Expand Up @@ -411,6 +474,35 @@ fn serialize_dry_run(workflow: &ResolvedWorkflow, strict: bool) -> DryRunPlan {
artifact_glob: artifact_glob.clone(),
artifact_name: artifact_name.clone(),
}),
ResolvedStep::Direct {
provider,
model,
policy,
text,
inject_files,
inject_files_raw,
wait_for_idle,
wait_timeout_secs,
startup_grace_secs,
artifact_glob,
artifact_name,
output_json,
..
} => steps.push(DryRunStep::Direct {
index: idx + 1,
provider: provider.clone(),
model: model.clone(),
policy: policy.clone(),
summary: text.clone(),
inject_files: inject_files.len(),
inject_files_raw: inject_files_raw.clone(),
wait_for_idle: *wait_for_idle,
wait_timeout_secs: *wait_timeout_secs,
startup_grace_secs: *startup_grace_secs,
artifact_glob: artifact_glob.clone(),
artifact_name: artifact_name.clone(),
output_json: output_json.as_ref().map(|path| path.display().to_string()),
}),
ResolvedStep::Command {
run,
cwd,
Expand Down Expand Up @@ -606,4 +698,42 @@ mod tests {
_ => panic!("expected command"),
}
}

#[test]
fn serialize_dry_run_contains_direct_metadata_and_prompt_summary() {
let workflow = ResolvedWorkflow {
name: "plan".to_string(),
description: None,
steps: vec![ResolvedStep::Direct {
step_id: Some("inspect".to_string()),
depends_on: vec![],
provider: "openai".to_string(),
model: "gpt-test".to_string(),
policy: "read_only".to_string(),
text: "Inspect the repository".to_string(),
inject_files: vec![],
inject_files_raw: vec!["docs/brief.md".to_string()],
output_json: None,
wait_for_idle: true,
wait_timeout_secs: 120,
startup_grace_secs: 5,
artifact_glob: Some("out/*.json".to_string()),
artifact_name: Some("plan".to_string()),
}],
};

let value = serde_json::to_value(serialize_dry_run(&workflow, false)).unwrap();
assert_eq!(value["steps"][0]["type"], "direct");
assert_eq!(value["steps"][0]["provider"], "openai");
assert_eq!(value["steps"][0]["model"], "gpt-test");
assert_eq!(value["steps"][0]["policy"], "read_only");
assert_eq!(value["steps"][0]["summary"], "Inspect the repository");
assert_eq!(value["steps"][0]["inject_files"], 0);
assert_eq!(value["steps"][0]["inject_files_raw"][0], "docs/brief.md");
assert_eq!(value["steps"][0]["wait_for_idle"], true);
assert_eq!(value["steps"][0]["wait_timeout_secs"], 120);
assert_eq!(value["steps"][0]["startup_grace_secs"], 5);
assert_eq!(value["steps"][0]["artifact_glob"], "out/*.json");
assert_eq!(value["steps"][0]["artifact_name"], "plan");
}
}
30 changes: 20 additions & 10 deletions src/cli/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,16 @@ fn execute_action(action: &str, body: &Value, target: &WorkspaceTarget) -> Resul
.and_then(Value::as_bool)
.unwrap_or(false);
with_project_root(&target.project_root, || {
super::run::run(Some(workflow), None, false, agent, false, strict, dry_run)
super::run::run(super::run::RunRequest {
workflow: Some(workflow),
resume: None,
list: false,
agent,
json: false,
strict,
dry_run,
direct: false,
})
})?;
Ok(json!({
"workspace": target.name,
Expand Down Expand Up @@ -843,15 +852,16 @@ fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result<V
for wh in &matched {
if let Some(workflow) = &wh.workflow {
with_project_root(&target.project_root, || {
super::run::run(
Some(workflow),
None,
false,
wh.agent.as_deref(),
false,
false,
false,
)
super::run::run(super::run::RunRequest {
workflow: Some(workflow),
resume: None,
list: false,
agent: wh.agent.as_deref(),
json: false,
strict: false,
dry_run: false,
direct: false,
})
})?;
webhook::log_event(
&target.project_root,
Expand Down
1 change: 1 addition & 0 deletions src/cli/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub fn run(
strict,
// Verify defaults to lenient unless strict is explicitly requested.
force_open_commands: !strict,
direct: false,
command_policy,
retry_policy,
origin: ExecutionOrigin::Verify,
Expand Down
Loading
Loading