From b6c82825370ed00744ee8531a6d168d43b23088a Mon Sep 17 00:00:00 2001 From: wren-nutthouse <268436207+wren-nutthouse@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:43:02 +1000 Subject: [PATCH 1/3] feat: add direct workflow dry-run contract --- src/automation/mod.rs | 217 ++++++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 19 ++++ src/cli/permissions.rs | 1 + src/cli/run.rs | 112 +++++++++++++++++---- src/cli/serve.rs | 30 ++++-- src/cli/verify.rs | 1 + src/config/mod.rs | 100 +++++++++++++++++++ src/main.rs | 12 ++- src/scheduler/mod.rs | 1 + 9 files changed, 459 insertions(+), 34 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 269fadb..acd3618 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -77,6 +77,7 @@ impl ExecutionOrigin { pub struct ExecuteOptions { pub strict: bool, pub force_open_commands: bool, + pub direct: bool, pub command_policy: Option, pub retry_policy: Option, pub origin: ExecutionOrigin, @@ -193,9 +194,33 @@ impl<'a> WorkflowResolver<'a> { startup_grace_secs, artifact_glob, artifact_name, + direct, + provider, + model, + policy, } => { let effective_agent = agent_override.unwrap_or(agent.as_str()); self.ensure_agent_exists(effective_agent)?; + if options.direct || direct.unwrap_or(false) { + let provider = resolve_direct_metadata( + "provider", + provider.as_deref(), + options.direct, + )?; + let model = + resolve_direct_metadata("model", model.as_deref(), options.direct)?; + steps.push(ResolvedStep::Direct { + step_id: id.clone(), + depends_on: depends_on.clone(), + provider, + model, + policy: policy.clone().unwrap_or_else(|| "read_only".to_string()), + text: text.clone(), + output_json: self + .resolve_prompt_output_path(effective_agent, output_json)?, + }); + continue; + } let session_name = TmuxSession::session_name(&self.config.workspace.name, effective_agent); let runtime = self @@ -450,6 +475,18 @@ fn resolve_optional_path(cwd: &Path, maybe: Option<&str>) -> Option { } } +fn resolve_direct_metadata(field: &str, value: Option<&str>, forced: bool) -> Result { + if let Some(value) = value { + return Ok(value.to_string()); + } + if forced { + return Ok("default".to_string()); + } + Err(TuttiError::ConfigValidation(format!( + "prompt step with direct = true requires {field}; set `{field} = \"...\"` or use `--direct --dry-run` to inspect placeholder resolution" + ))) +} + fn effective_fail_mode( configured: Option, strict: bool, @@ -500,6 +537,15 @@ pub enum ResolvedStep { artifact_glob: Option, artifact_name: Option, }, + Direct { + step_id: Option, + depends_on: Vec, + provider: String, + model: String, + policy: String, + text: String, + output_json: Option, + }, Command { step_id: Option, depends_on: Vec, @@ -541,6 +587,7 @@ pub enum ResolvedStep { fn step_depends_on(step: &ResolvedStep) -> &[usize] { match step { ResolvedStep::Prompt { depends_on, .. } => depends_on, + ResolvedStep::Direct { depends_on, .. } => depends_on, ResolvedStep::Command { depends_on, .. } => depends_on, ResolvedStep::EnsureRunning { depends_on, .. } => depends_on, ResolvedStep::Workflow { depends_on, .. } => depends_on, @@ -561,6 +608,7 @@ fn step_is_control(step: &ResolvedStep) -> bool { fn step_type_name(step: &ResolvedStep) -> &'static str { match step { ResolvedStep::Prompt { .. } => "prompt", + ResolvedStep::Direct { .. } => "direct", ResolvedStep::Command { .. } => "command", ResolvedStep::EnsureRunning { .. } => "ensure_running", ResolvedStep::Workflow { .. } => "workflow", @@ -572,6 +620,7 @@ fn step_type_name(step: &ResolvedStep) -> &'static str { fn step_agent_name(step: &ResolvedStep) -> Option<&str> { match step { ResolvedStep::Prompt { agent, .. } => Some(agent), + ResolvedStep::Direct { .. } => None, ResolvedStep::Command { agent, .. } => agent.as_deref(), ResolvedStep::EnsureRunning { agent, .. } => Some(agent), ResolvedStep::Workflow { .. } => None, @@ -595,7 +644,9 @@ fn sanitize_step_key(input: &str) -> String { fn step_file_key(step: &ResolvedStep, step_index: usize) -> String { let base = match step { - ResolvedStep::Prompt { step_id, .. } | ResolvedStep::Command { step_id, .. } => { + ResolvedStep::Prompt { step_id, .. } + | ResolvedStep::Direct { step_id, .. } + | ResolvedStep::Command { step_id, .. } => { step_id.as_deref().unwrap_or_else(|| step_type_name(step)) } _ => step_type_name(step), @@ -735,6 +786,19 @@ impl<'a> WorkflowExecutor<'a> { run_id: Option<&str>, resume: Option<&ResumeContext>, ) -> Result { + if let Some((idx, _)) = workflow + .steps + .iter() + .enumerate() + .find(|(_, step)| matches!(step, ResolvedStep::Direct { .. })) + { + return Err(TuttiError::ConfigValidation(format!( + "workflow '{}' step {} uses direct execution, but direct execution is not implemented yet; use `tt run {} --dry-run` (optionally with `--json`) to inspect the plan", + workflow.name, + idx + 1, + workflow.name + ))); + } let started_at = resume.map(|r| r.started_at).unwrap_or_else(Utc::now); let run_id = run_id .map(ToString::to_string) @@ -840,6 +904,9 @@ impl<'a> WorkflowExecutor<'a> { break; } match step { + ResolvedStep::Direct { .. } => { + unreachable!("direct steps are rejected before workflow execution") + } ResolvedStep::Prompt { agent, step_id, @@ -2222,6 +2289,7 @@ impl<'a> WorkflowExecutor<'a> { let nested_options = ExecuteOptions { strict: *strict, force_open_commands: options.force_open_commands, + direct: options.direct, command_policy: options.command_policy.clone(), retry_policy: options.retry_policy, origin: options.origin, @@ -3737,6 +3805,18 @@ fn step_intent_payload(step: &ResolvedStep) -> Value { "artifact_glob": artifact_glob, "artifact_name": artifact_name, }), + ResolvedStep::Direct { + provider, + model, + policy, + text, + .. + } => json!({ + "provider": provider, + "model": model, + "policy": policy, + "text_chars": text.chars().count(), + }), ResolvedStep::Command { run, cwd, @@ -4689,6 +4769,7 @@ fn dispatch_agent_stop_hook( let options = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: load_command_policy(), retry_policy: load_retry_policy(), origin: ExecutionOrigin::HookAgentStop, @@ -4766,6 +4847,7 @@ fn dispatch_workflow_complete_hook( let options = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: load_command_policy(), retry_policy: load_retry_policy(), origin: ExecutionOrigin::HookWorkflowComplete, @@ -5085,6 +5167,125 @@ mod tests { String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn prompt_workflow( + direct: Option, + provider: Option<&str>, + model: Option<&str>, + policy: Option<&str>, + ) -> WorkflowConfig { + WorkflowConfig { + name: "plan".to_string(), + description: None, + schedule: None, + steps: vec![WorkflowStepConfig::Prompt { + id: Some("inspect".to_string()), + depends_on: vec![], + agent: "backend".to_string(), + text: "Inspect the repository".to_string(), + inject_files: vec![], + output_json: None, + wait_for_idle: None, + wait_timeout_secs: None, + startup_grace_secs: None, + artifact_glob: None, + artifact_name: None, + direct, + provider: provider.map(str::to_string), + model: model.map(str::to_string), + policy: policy.map(str::to_string), + }], + } + } + + fn run_options(direct: bool) -> ExecuteOptions { + ExecuteOptions { + strict: false, + force_open_commands: false, + direct, + command_policy: None, + retry_policy: None, + origin: ExecutionOrigin::Run, + hook_event: None, + hook_agent: None, + } + } + + #[test] + fn resolver_selects_direct_step_from_prompt_metadata() { + let dir = tempfile::tempdir().unwrap(); + let config = sample_config( + prompt_workflow(Some(true), Some("openai"), Some("gpt-test"), None), + vec![], + ); + + let resolved = WorkflowResolver::new(&config, dir.path()) + .resolve("plan", None, &run_options(false)) + .unwrap(); + + match &resolved.steps[0] { + ResolvedStep::Direct { + provider, + model, + policy, + text, + .. + } => { + assert_eq!(provider, "openai"); + assert_eq!(model, "gpt-test"); + assert_eq!(policy, "read_only"); + assert_eq!(text, "Inspect the repository"); + } + _ => panic!("expected direct step"), + } + } + + #[test] + fn resolver_cli_direct_override_uses_placeholder_metadata() { + let dir = tempfile::tempdir().unwrap(); + let config = sample_config(prompt_workflow(Some(false), None, None, None), vec![]); + + let resolved = WorkflowResolver::new(&config, dir.path()) + .resolve("plan", None, &run_options(true)) + .unwrap(); + + match &resolved.steps[0] { + ResolvedStep::Direct { + provider, + model, + policy, + .. + } => { + assert_eq!(provider, "default"); + assert_eq!(model, "default"); + assert_eq!(policy, "read_only"); + } + _ => panic!("expected direct step"), + } + } + + #[test] + fn direct_step_execution_returns_actionable_error() { + let dir = tempfile::tempdir().unwrap(); + let config = sample_config( + prompt_workflow(Some(true), Some("openai"), Some("gpt-test"), None), + vec![], + ); + let options = run_options(false); + let resolved = WorkflowResolver::new(&config, dir.path()) + .resolve("plan", None, &options) + .unwrap(); + + let err = WorkflowExecutor::new(&config, dir.path()) + .execute(&resolved, &options, None, None, None) + .unwrap_err(); + + assert!( + err.to_string() + .contains("direct execution is not implemented") + ); + assert!(err.to_string().contains("--dry-run")); + } + #[test] fn command_fail_open_continues() { let workflow = WorkflowConfig { @@ -5137,6 +5338,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5207,6 +5409,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5253,6 +5456,7 @@ mod tests { let opts = ExecuteOptions { strict: true, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Verify, @@ -5297,6 +5501,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5344,6 +5549,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5389,6 +5595,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: Some(PermissionsConfig { allow: vec!["git status".to_string()], }), @@ -5469,6 +5676,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: Some(PermissionsConfig { allow: vec!["git status".to_string()], }), @@ -5528,6 +5736,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5815,6 +6024,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -5971,6 +6181,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -6052,6 +6263,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -6134,6 +6346,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -6178,6 +6391,7 @@ mod tests { let opts = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, @@ -6336,6 +6550,7 @@ mod tests { let options = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: None, retry_policy: None, origin: ExecutionOrigin::Run, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 00a8296..c9371c9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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 @@ -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"), + } + } +} diff --git a/src/cli/permissions.rs b/src/cli/permissions.rs index d0ae37c..c3ff0f2 100644 --- a/src/cli/permissions.rs +++ b/src/cli/permissions.rs @@ -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, diff --git a/src/cli/run.rs b/src/cli/run.rs index 7bf25f0..594381b 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -10,21 +10,24 @@ 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(()); } @@ -32,7 +35,7 @@ pub fn run( 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 @@ -41,14 +44,14 @@ 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()) @@ -56,7 +59,7 @@ pub fn run( 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 @@ -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, @@ -113,7 +117,7 @@ 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 { @@ -129,7 +133,7 @@ pub fn run( ))); } } - if json { + if request.json { println!( "{}", serde_json::to_string_pretty(&serialize_dry_run(&resolved, effective_strict))? @@ -148,7 +152,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); @@ -261,6 +265,30 @@ fn print_dry_run(workflow: &crate::automation::ResolvedWorkflow, strict: bool) { truncate(&summary, 80), ]) } + ResolvedStep::Direct { + provider, + model, + policy, + text, + output_json, + .. + } => table.add_row(vec![ + (idx + 1).to_string(), + "direct".to_string(), + "--".to_string(), + "workspace".to_string(), + "closed".to_string(), + truncate( + &format!( + "provider:{provider} model:{model} policy:{policy} prompt:{text}{}", + output_json + .as_ref() + .map(|path| format!(" [output:{}]", path.display())) + .unwrap_or_default() + ), + 80, + ), + ]), crate::automation::ResolvedStep::Command { run, cwd, @@ -357,6 +385,15 @@ enum DryRunStep { #[serde(skip_serializing_if = "Option::is_none")] artifact_name: Option, }, + Direct { + index: usize, + provider: String, + model: String, + policy: String, + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + output_json: Option, + }, Command { index: usize, agent: Option, @@ -411,6 +448,21 @@ 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, + output_json, + .. + } => steps.push(DryRunStep::Direct { + index: idx + 1, + provider: provider.clone(), + model: model.clone(), + policy: policy.clone(), + summary: text.clone(), + output_json: output_json.as_ref().map(|path| path.display().to_string()), + }), ResolvedStep::Command { run, cwd, @@ -606,4 +658,28 @@ 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(), + output_json: None, + }], + }; + + 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"); + } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index dc0e3da..45524a8 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -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, @@ -843,15 +852,16 @@ fn route_webhook(request: &mut Request, targets: &[WorkspaceTarget]) -> Result, #[serde(default)] artifact_name: Option, + #[serde(default)] + direct: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + model: Option, + #[serde(default)] + policy: Option, }, Command { #[serde(default)] @@ -830,6 +838,10 @@ impl TuttiConfig { startup_grace_secs, artifact_glob, artifact_name, + direct, + provider, + model, + policy, .. } => { // Allow wait_timeout_secs/startup_grace_secs when artifact_glob @@ -859,6 +871,26 @@ impl TuttiConfig { idx + 1 ))); } + for (field, value) in [ + ("provider", provider.as_deref()), + ("model", model.as_deref()), + ("policy", policy.as_deref()), + ] { + if value.is_some_and(|value| value.trim().is_empty()) { + return Err(TuttiError::ConfigValidation(format!( + "workflow '{}', step {} has empty direct {field}", + workflow.name, + idx + 1 + ))); + } + } + if direct.unwrap_or(false) && (provider.is_none() || model.is_none()) { + return Err(TuttiError::ConfigValidation(format!( + "workflow '{}', step {} sets direct = true but is missing provider/model; set both fields explicitly", + workflow.name, + idx + 1 + ))); + } for path in inject_files { let trimmed = path.trim(); if trimmed.is_empty() { @@ -2470,6 +2502,74 @@ name = "test" assert!(config.webhooks.is_empty()); } + #[test] + fn direct_prompt_metadata_parses_from_toml() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "planner" +runtime = "claude-code" + +[[workflow]] +name = "plan" + +[[workflow.step]] +type = "prompt" +agent = "planner" +text = "Inspect the repository" +direct = true +provider = "openai" +model = "gpt-test" +policy = "workspace_write" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + config.validate().unwrap(); + + match &config.workflows[0].steps[0] { + WorkflowStepConfig::Prompt { + direct, + provider, + model, + policy, + .. + } => { + assert_eq!(*direct, Some(true)); + assert_eq!(provider.as_deref(), Some("openai")); + assert_eq!(model.as_deref(), Some("gpt-test")); + assert_eq!(policy.as_deref(), Some("workspace_write")); + } + _ => panic!("expected prompt"), + } + } + + #[test] + fn direct_prompt_requires_explicit_provider_and_model() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "planner" + +[[workflow]] +name = "plan" + +[[workflow.step]] +type = "prompt" +agent = "planner" +text = "Inspect the repository" +direct = true +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + + assert!(err.to_string().contains("direct = true")); + assert!(err.to_string().contains("provider")); + assert!(err.to_string().contains("model")); + } + #[test] fn artifact_glob_and_name_parse_from_toml() { let toml_str = r#" diff --git a/src/main.rs b/src/main.rs index 0585282..ae8a22d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,15 +137,17 @@ fn main() { json, strict, dry_run, - } => cli::run::run( - workflow.as_deref(), - resume.as_deref(), + direct, + } => cli::run::run(cli::run::RunRequest { + workflow: workflow.as_deref(), + resume: resume.as_deref(), list, - agent.as_deref(), + agent: agent.as_deref(), json, strict, dry_run, - ), + direct, + }), Commands::Runs { command } => match command { RunsSubcommand::List => cli::runs::list(), RunsSubcommand::Show { ref run_id } => cli::runs::show(run_id), diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index ec0c1f0..b58114c 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -63,6 +63,7 @@ pub fn run_due_workflows_for_workspace( let options = ExecuteOptions { strict: false, force_open_commands: false, + direct: false, command_policy: command_policy.clone(), retry_policy, origin: ExecutionOrigin::ObserveCycle, From b935ec9328a7665e65bc4f5851b36a48613f50a1 Mon Sep 17 00:00:00 2001 From: wren-nutthouse <268436207+wren-nutthouse@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:55:39 +1000 Subject: [PATCH 2/3] fix: preserve direct dry-run prompt contract --- src/automation/mod.rs | 87 +++++++++++++++++++++++++++++++++++++++++++ src/cli/run.rs | 87 ++++++++++++++++++++++++++++++++++--------- src/config/mod.rs | 39 +++++++++++++++++++ 3 files changed, 196 insertions(+), 17 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index acd3618..01707f3 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -216,8 +216,17 @@ impl<'a> WorkflowResolver<'a> { model, policy: policy.clone().unwrap_or_else(|| "read_only".to_string()), text: text.clone(), + inject_files: self + .resolve_prompt_injected_files(effective_agent, inject_files), + inject_files_raw: inject_files.clone(), output_json: self .resolve_prompt_output_path(effective_agent, output_json)?, + wait_for_idle: wait_for_idle.unwrap_or(false), + wait_timeout_secs: wait_timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS), + startup_grace_secs: startup_grace_secs + .unwrap_or(DEFAULT_STARTUP_GRACE_SECS), + artifact_glob: artifact_glob.clone(), + artifact_name: artifact_name.clone(), }); continue; } @@ -544,7 +553,14 @@ pub enum ResolvedStep { model: String, policy: String, text: String, + inject_files: Vec, + inject_files_raw: Vec, output_json: Option, + wait_for_idle: bool, + wait_timeout_secs: u64, + startup_grace_secs: u64, + artifact_glob: Option, + artifact_name: Option, }, Command { step_id: Option, @@ -5228,12 +5244,83 @@ mod tests { model, policy, text, + inject_files, + inject_files_raw, + wait_for_idle, + wait_timeout_secs, + startup_grace_secs, + artifact_glob, + artifact_name, .. } => { assert_eq!(provider, "openai"); assert_eq!(model, "gpt-test"); assert_eq!(policy, "read_only"); assert_eq!(text, "Inspect the repository"); + assert!(inject_files.is_empty()); + assert!(inject_files_raw.is_empty()); + assert!(!wait_for_idle); + assert_eq!(*wait_timeout_secs, DEFAULT_TIMEOUT_SECS); + assert_eq!(*startup_grace_secs, DEFAULT_STARTUP_GRACE_SECS); + assert!(artifact_glob.is_none()); + assert!(artifact_name.is_none()); + } + _ => panic!("expected direct step"), + } + } + + #[test] + fn resolver_direct_step_preserves_prompt_inputs_and_controls() { + let dir = tempfile::tempdir().unwrap(); + let mut workflow = prompt_workflow( + Some(true), + Some("openai"), + Some("gpt-test"), + Some("workspace_write"), + ); + if let WorkflowStepConfig::Prompt { + inject_files, + wait_for_idle, + wait_timeout_secs, + startup_grace_secs, + artifact_glob, + artifact_name, + .. + } = &mut workflow.steps[0] + { + inject_files.push("docs/brief.md".to_string()); + *wait_for_idle = Some(true); + *wait_timeout_secs = Some(123); + *startup_grace_secs = Some(7); + *artifact_glob = Some("out/*.json".to_string()); + *artifact_name = Some("plan".to_string()); + } + let config = sample_config(workflow, vec![]); + + let resolved = WorkflowResolver::new(&config, dir.path()) + .resolve("plan", None, &run_options(false)) + .unwrap(); + + match &resolved.steps[0] { + ResolvedStep::Direct { + inject_files, + inject_files_raw, + wait_for_idle, + wait_timeout_secs, + startup_grace_secs, + artifact_glob, + artifact_name, + policy, + .. + } => { + assert_eq!(policy, "workspace_write"); + assert_eq!(inject_files.len(), 1); + assert_eq!(inject_files_raw, &vec!["docs/brief.md".to_string()]); + assert!(*wait_for_idle); + assert_eq!(*wait_timeout_secs, 123); + assert_eq!(*startup_grace_secs, 7); + assert_eq!(artifact_glob.as_deref(), Some("out/*.json")); + assert_eq!(artifact_name.as_deref(), Some("plan")); } _ => panic!("expected direct step"), } diff --git a/src/cli/run.rs b/src/cli/run.rs index 594381b..f9e1206 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -270,25 +270,41 @@ fn print_dry_run(workflow: &crate::automation::ResolvedWorkflow, strict: bool) { model, policy, text, + inject_files, + artifact_glob, + artifact_name, + wait_for_idle, + wait_timeout_secs, + startup_grace_secs, output_json, .. - } => table.add_row(vec![ - (idx + 1).to_string(), - "direct".to_string(), - "--".to_string(), - "workspace".to_string(), - "closed".to_string(), - truncate( - &format!( - "provider:{provider} model:{model} policy:{policy} prompt:{text}{}", - output_json - .as_ref() - .map(|path| format!(" [output:{}]", path.display())) - .unwrap_or_default() - ), - 80, - ), - ]), + } => { + 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, @@ -391,6 +407,15 @@ enum DryRunStep { model: String, policy: String, summary: String, + inject_files: usize, + inject_files_raw: Vec, + wait_for_idle: bool, + wait_timeout_secs: u64, + startup_grace_secs: u64, + #[serde(skip_serializing_if = "Option::is_none")] + artifact_glob: Option, + #[serde(skip_serializing_if = "Option::is_none")] + artifact_name: Option, #[serde(skip_serializing_if = "Option::is_none")] output_json: Option, }, @@ -453,6 +478,13 @@ fn serialize_dry_run(workflow: &ResolvedWorkflow, strict: bool) -> DryRunPlan { 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 { @@ -461,6 +493,13 @@ fn serialize_dry_run(workflow: &ResolvedWorkflow, strict: bool) -> DryRunPlan { 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 { @@ -671,7 +710,14 @@ mod tests { 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()), }], }; @@ -681,5 +727,12 @@ mod tests { 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"); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9b918c7..a575510 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -884,6 +884,16 @@ impl TuttiConfig { ))); } } + if let Some(policy) = policy.as_deref() + && !matches!(policy, "read_only" | "workspace_write") + { + return Err(TuttiError::ConfigValidation(format!( + "workflow '{}', step {} has unsupported direct policy '{}'; supported policies are read_only and workspace_write", + workflow.name, + idx + 1, + policy + ))); + } if direct.unwrap_or(false) && (provider.is_none() || model.is_none()) { return Err(TuttiError::ConfigValidation(format!( "workflow '{}', step {} sets direct = true but is missing provider/model; set both fields explicitly", @@ -2570,6 +2580,35 @@ direct = true assert!(err.to_string().contains("model")); } + #[test] + fn direct_prompt_rejects_unknown_policy() { + let toml_str = r#" +[workspace] +name = "test" + +[[agent]] +name = "planner" + +[[workflow]] +name = "plan" + +[[workflow.step]] +type = "prompt" +agent = "planner" +text = "Inspect the repository" +direct = true +provider = "openai" +model = "gpt-test" +policy = "read_ony" +"#; + let config: TuttiConfig = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err(); + + assert!(err.to_string().contains("unsupported direct policy")); + assert!(err.to_string().contains("read_only")); + assert!(err.to_string().contains("workspace_write")); + } + #[test] fn artifact_glob_and_name_parse_from_toml() { let toml_str = r#" From 86c8898d58c50dcbd0867df04a36bc337a334c37 Mon Sep 17 00:00:00 2001 From: wren-nutthouse <268436207+wren-nutthouse@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:59:36 +1000 Subject: [PATCH 3/3] fix: validate direct artifact dry-run prerequisites --- src/cli/run.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/cli/run.rs b/src/cli/run.rs index f9e1206..c8c7a6b 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -120,17 +120,18 @@ pub fn run(request: RunRequest<'_>) -> Result<()> { 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 request.json {