diff --git a/USAGE.md b/USAGE.md index c02fc1b..303974e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -630,6 +630,55 @@ Example script and workflow are available in the [examples/polling-simple](examp --- +### `dispatch` + +Fires only when another workflow (or the CLI) hands it a run via `otter dispatch`. +The workflow stays idle until a dispatch arrives; it never polls and never fires on +its own. This lets one workflow programmatically start another **with data**: a +payload string plus a set of files that pre-populate the run's `trigger-context/` +— the same directory a polling `context_command` would write. + +**Fields:** +- `type` (required): `"dispatch"` + +**Example:** +```toml +name = "on-demand-handler" +type = "triggered" + +[trigger] +type = "dispatch" + +[[steps]] +type = "shell" +command = ["cat", "trigger-context/summary.txt"] +``` + +**Usage:** +```bash +# Hand a run to a running dispatch-triggered workflow, with context files. +otter dispatch on-demand-handler \ + --payload "change-42" \ + --context-dir ./my-context-dir \ + --context-file summary.txt=/tmp/summary.txt +``` + +**Behavior:** +- The target workflow must be **running** (started or enabled) — its engine + registers the dispatch inbox on start. Dispatching to a stopped workflow, or to a + workflow whose trigger is not `dispatch`, returns an error. +- `--context-dir ` copies every regular file in `` into + `trigger-context/`. `--context-file =` adds a single file under + ``. Both may be combined and `--context-file` repeated. Context file + contents must be valid UTF-8 — they are carried as text, so binary files are + rejected at dispatch time. +- `--payload ` is recorded as the run's trigger payload. +- Each dispatch fires exactly one run; dispatches arriving while a run is in progress + are queued (same as polling events). +- File names are restricted to plain names; path-traversal entries are skipped. + +--- + ## Workflow management A **workflow package** is a directory containing a `workflow.toml` plus any companion scripts used by the workflow's steps. Companion scripts in the package directory are automatically prepended to `PATH` when any step in that workflow runs. diff --git a/crates/otter-cli/src/daemon.rs b/crates/otter-cli/src/daemon.rs index dcff627..0557ec9 100644 --- a/crates/otter-cli/src/daemon.rs +++ b/crates/otter-cli/src/daemon.rs @@ -447,6 +447,22 @@ async fn handle_connection( } let _ = write_json(&mut writer, &result_to_response(result)).await; } + DaemonCommand::Dispatch { + workflow, + payload, + context_files, + } => { + info!(workflow = %workflow, "Dispatch workflow requested"); + let result = manager + .lock() + .await + .dispatch(&workflow, payload, context_files) + .await; + if let Err(e) = &result { + warn!(workflow = %workflow, error = %e, "Dispatch workflow failed"); + } + let _ = write_json(&mut writer, &result_to_response(result)).await; + } DaemonCommand::Stop { name } => { info!(workflow = %name, "Stop workflow requested"); let result = manager.lock().await.stop(&name).await; diff --git a/crates/otter-cli/src/main.rs b/crates/otter-cli/src/main.rs index 5b93129..b76d173 100644 --- a/crates/otter-cli/src/main.rs +++ b/crates/otter-cli/src/main.rs @@ -44,6 +44,21 @@ enum Commands { Ui, /// Start a dormant workflow Start { name: String }, + /// Hand a one-off run to a running `dispatch`-triggered workflow, passing a + /// payload and/or files to pre-populate its `trigger-context/`. + Dispatch { + /// Name of the target dispatch-triggered workflow + workflow: String, + /// Optional payload string, available to the run as the trigger payload + #[arg(long)] + payload: Option, + /// A file to place in trigger-context/, as `name=path` (repeatable) + #[arg(long = "context-file", value_name = "NAME=PATH")] + context_file: Vec, + /// A directory whose files are all copied into trigger-context/ + #[arg(long = "context-dir", value_name = "DIR")] + context_dir: Option, + }, /// Stop a running workflow Stop { name: String }, /// Print the status of all registered workflows @@ -209,6 +224,41 @@ enum TriggersCommands { DeleteConsumed { workflow: String, trigger: String }, } +/// Build the `(filename, contents)` list for `otter dispatch` from any +/// `--context-file name=path` args plus every file in an optional `--context-dir`. +fn collect_context_files( + context_file: &[String], + context_dir: Option<&str>, +) -> anyhow::Result> { + let mut files = Vec::new(); + + if let Some(dir) = context_dir { + for entry in std::fs::read_dir(dir) + .map_err(|e| anyhow::anyhow!("reading --context-dir {dir}: {e}"))? + { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let contents = std::fs::read_to_string(entry.path()) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", entry.path().display()))?; + files.push((name, contents)); + } + } + + for spec in context_file { + let (name, path) = spec + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("--context-file must be NAME=PATH, got '{spec}'"))?; + let contents = + std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + files.push((name.to_string(), contents)); + } + + Ok(files) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -241,6 +291,20 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Start { name }) => { client::send_command_print(DaemonCommand::Start { name }).await } + Some(Commands::Dispatch { + workflow, + payload, + context_file, + context_dir, + }) => { + let context_files = collect_context_files(&context_file, context_dir.as_deref())?; + client::send_command_print(DaemonCommand::Dispatch { + workflow, + payload, + context_files, + }) + .await + } Some(Commands::Stop { name }) => { client::send_command_print(DaemonCommand::Stop { name }).await } diff --git a/crates/otter-core/src/engine.rs b/crates/otter-core/src/engine.rs index a8b56ab..a15bd57 100644 --- a/crates/otter-core/src/engine.rs +++ b/crates/otter-core/src/engine.rs @@ -29,6 +29,7 @@ pub struct Engine { scripts_dir: Option, secret_store: Arc, requirements: Option>, + dispatch_registry: Option, } impl Engine { @@ -45,6 +46,7 @@ impl Engine { scripts_dir: None, secret_store: Arc::new(NoOpSecretStore), requirements: None, + dispatch_registry: None, } } @@ -62,6 +64,7 @@ impl Engine { scripts_dir, secret_store: Arc::new(NoOpSecretStore), requirements: None, + dispatch_registry: None, } } @@ -80,6 +83,7 @@ impl Engine { scripts_dir, secret_store, requirements: None, + dispatch_registry: None, } } @@ -90,6 +94,16 @@ impl Engine { self } + /// Builder-style setter for the shared dispatch inbox registry. Required for + /// `dispatch`-triggered workflows so they can register and receive runs. + pub fn with_dispatch_registry( + mut self, + registry: Option, + ) -> Self { + self.dispatch_registry = registry; + self + } + pub fn with_executors( storage: Arc, scratch_base: std::path::PathBuf, @@ -103,6 +117,7 @@ impl Engine { scripts_dir: None, secret_store: Arc::new(NoOpSecretStore), requirements: None, + dispatch_registry: None, } } @@ -258,6 +273,7 @@ impl Engine { self.scripts_dir.as_deref(), self.secret_store.clone(), self.requirements.clone(), + self.dispatch_registry.clone(), )?; let (trigger_tx, mut trigger_rx) = mpsc::channel::(32); @@ -467,6 +483,27 @@ impl Engine { } } + // Materialize inline context files (from a `dispatch` trigger) into + // trigger-context/ — the inline counterpart to a polling context command. + if let Some(files) = event.and_then(|e| e.inline_context.as_ref()) { + let ctx_dir = match &workspace_dir { + Some(ws) => ws.join("trigger-context"), + None => scratch_dir.join("trigger-context"), + }; + std::fs::create_dir_all(&ctx_dir)?; + for (name, contents) in files { + // Guard against path traversal: only allow plain file names. + let safe = std::path::Path::new(name) + .file_name() + .map(|n| n == std::ffi::OsStr::new(name)); + if safe != Some(true) { + warn!(run_id = %run.id, "skipping unsafe inline context file name {:?}", name); + continue; + } + std::fs::write(ctx_dir.join(name), contents)?; + } + } + let stop = if context_failed { true } else { diff --git a/crates/otter-core/src/engine_tests.rs b/crates/otter-core/src/engine_tests.rs index 671bac0..d0ffda6 100644 --- a/crates/otter-core/src/engine_tests.rs +++ b/crates/otter-core/src/engine_tests.rs @@ -237,6 +237,56 @@ async fn triggered_workflow_runs_once_per_event() { shutdown.store(true, Ordering::Relaxed); } +#[tokio::test] +async fn inline_context_is_written_to_trigger_context() { + // GIVEN a run seeded with an event carrying inline trigger-context files + let storage = Arc::new(InMemoryStorage::new()); + let temp = tempfile::tempdir().unwrap(); + let scratch = temp.path().to_path_buf(); + let engine = Engine::new( + storage, + scratch.clone(), + Arc::new(otter_notify::NoOpNotifier), + ); + + let run_id = uuid::Uuid::new_v4(); + let event = TriggerEvent { + source: "dispatch".to_string(), + payload: "ch-42".to_string(), + preallocated_run_id: Some(run_id), + pending_context: None, + inline_context: Some(vec![ + ("summary.txt".to_string(), "Change: 42".to_string()), + ("otter_command.txt".to_string(), "review".to_string()), + // A traversal attempt must be ignored, not written. + ("../escape.txt".to_string(), "nope".to_string()), + ]), + }; + + let wf = workflow("inline-ctx", WorkflowType::Triggered, vec![]); + let shutdown = Arc::new(AtomicBool::new(false)); + + // WHEN the run executes + engine + .run_once(&wf, Some(&event), shutdown, None) + .await + .unwrap(); + + // THEN the inline files land in the run's trigger-context/ (scratch workspace) + let ctx = scratch.join(run_id.to_string()).join("trigger-context"); + assert_eq!( + std::fs::read_to_string(ctx.join("summary.txt")).unwrap(), + "Change: 42" + ); + assert_eq!( + std::fs::read_to_string(ctx.join("otter_command.txt")).unwrap(), + "review" + ); + // AND the path-traversal entry was skipped: had the guard failed, joining + // "../escape.txt" onto trigger-context/ would have landed it in the run dir. + assert!(!scratch.join(run_id.to_string()).join("escape.txt").exists()); +} + #[tokio::test] async fn stop_prevents_next_iteration() { // GIVEN a looping workflow with a fast shell step diff --git a/crates/otter-core/src/triggers/dispatch.rs b/crates/otter-core/src/triggers/dispatch.rs new file mode 100644 index 0000000..1b4a3ba --- /dev/null +++ b/crates/otter-core/src/triggers/dispatch.rs @@ -0,0 +1,116 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tokio::sync::mpsc; + +use crate::types::{TriggerError, TriggerEvent}; + +use super::TriggerSource; + +/// A run handed to a `dispatch`-triggered workflow by `otter dispatch`. +#[derive(Debug, Clone)] +pub struct DispatchMsg { + pub payload: String, + /// `(filename, contents)` pairs written into the run's `trigger-context/`. + pub context_files: Vec<(String, String)>, +} + +/// Maps a running `dispatch`-triggered workflow name to the sender of its inbox. +/// Owned by the workflow manager; the daemon pushes into it on `DaemonCommand::Dispatch`, +/// and each `DispatchTrigger` registers its sender here when its engine starts. +pub type DispatchRegistry = Arc>>>; + +/// Trigger that only fires when another workflow dispatches a run to it. +pub struct DispatchTrigger { + name: String, + inbox: Mutex>>, +} + +impl DispatchTrigger { + /// Create the trigger and register its inbox sender under `workflow` in `registry`, + /// replacing any previous registration (e.g. from an earlier engine start). + pub fn new(name: impl Into, workflow: impl Into, registry: DispatchRegistry) -> Self { + let (tx, rx) = mpsc::channel::(32); + // Recover from a poisoned lock rather than panicking the engine task: the + // registry is a plain name→sender map, so a panic elsewhere can't leave it + // in a state that makes inserting unsafe. + registry + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(workflow.into(), tx); + Self { + name: name.into(), + inbox: Mutex::new(Some(rx)), + } + } +} + +#[async_trait] +impl TriggerSource for DispatchTrigger { + fn name(&self) -> &str { + &self.name + } + + async fn subscribe(&self, tx: mpsc::Sender) -> Result<(), TriggerError> { + let mut rx = self + .inbox + .lock() + .map_err(|_| TriggerError::Failed("dispatch inbox poisoned".to_string()))? + .take() + .ok_or_else(|| TriggerError::Failed("dispatch inbox already consumed".to_string()))?; + + while let Some(msg) = rx.recv().await { + let event = TriggerEvent { + source: self.name.clone(), + payload: msg.payload, + preallocated_run_id: None, + pending_context: None, + inline_context: Some(msg.context_files), + }; + if tx.send(event).await.is_err() { + break; + } + } + Ok(()) + } + + /// A dispatch trigger never fires on its own; runs only arrive via the inbox. + async fn fire_once(&self, _tx: mpsc::Sender) -> Result<(), TriggerError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn dispatched_message_becomes_trigger_event() { + // GIVEN a dispatch trigger registered in a registry + let registry: DispatchRegistry = Arc::new(Mutex::new(HashMap::new())); + let trigger = DispatchTrigger::new("dispatch", "wf", registry.clone()); + let (event_tx, mut event_rx) = mpsc::channel::(8); + let handle = tokio::spawn(async move { trigger.subscribe(event_tx).await }); + + // WHEN a message is pushed to the workflow's inbox + let sender = registry.lock().unwrap().get("wf").unwrap().clone(); + sender + .send(DispatchMsg { + payload: "ch-42".to_string(), + context_files: vec![("summary.txt".to_string(), "hello".to_string())], + }) + .await + .unwrap(); + + // THEN subscribe emits a matching trigger event with inline context + let event = event_rx.recv().await.expect("expected one event"); + assert_eq!(event.payload, "ch-42"); + assert_eq!( + event.inline_context.as_deref(), + Some(&[("summary.txt".to_string(), "hello".to_string())][..]) + ); + + handle.abort(); + } +} diff --git a/crates/otter-core/src/triggers/manual.rs b/crates/otter-core/src/triggers/manual.rs index 7ce6e82..50062fc 100644 --- a/crates/otter-core/src/triggers/manual.rs +++ b/crates/otter-core/src/triggers/manual.rs @@ -31,6 +31,7 @@ impl TriggerSource for ManualTrigger { payload: String::new(), preallocated_run_id: None, pending_context: None, + inline_context: None, }) .await .map_err(|e| TriggerError::Failed(e.to_string()))?; @@ -45,6 +46,7 @@ impl TriggerSource for ManualTrigger { payload: String::new(), preallocated_run_id: None, pending_context: None, + inline_context: None, }) .await .map_err(|e| TriggerError::Failed(e.to_string())) diff --git a/crates/otter-core/src/triggers/mod.rs b/crates/otter-core/src/triggers/mod.rs index 3271b43..6765a70 100644 --- a/crates/otter-core/src/triggers/mod.rs +++ b/crates/otter-core/src/triggers/mod.rs @@ -1,7 +1,10 @@ +pub mod dispatch; pub mod manual; pub mod oneshot; pub mod polling; +pub use dispatch::{DispatchMsg, DispatchRegistry}; + use async_trait::async_trait; use std::path::Path; use std::sync::Arc; @@ -32,6 +35,7 @@ pub fn build_trigger( scripts_dir: Option<&Path>, secret_store: Arc, requirements: Option>, + dispatch_registry: Option, ) -> Result, anyhow::Error> { match def { TriggerDef::Manual => { @@ -63,5 +67,18 @@ pub fn build_trigger( requires.clone().unwrap_or_default(), ))) } + TriggerDef::Dispatch => { + let registry = dispatch_registry.ok_or_else(|| { + anyhow::anyhow!( + "dispatch trigger for '{}' requires a dispatch registry", + workflow_name + ) + })?; + Ok(Arc::new(dispatch::DispatchTrigger::new( + "dispatch".to_string(), + workflow_name.to_string(), + registry, + ))) + } } } diff --git a/crates/otter-core/src/triggers/oneshot.rs b/crates/otter-core/src/triggers/oneshot.rs index 8b473c8..6ff328e 100644 --- a/crates/otter-core/src/triggers/oneshot.rs +++ b/crates/otter-core/src/triggers/oneshot.rs @@ -29,6 +29,7 @@ impl TriggerSource for OneShotTrigger { payload: String::new(), preallocated_run_id: None, pending_context: None, + inline_context: None, }) .await .map_err(|_| TriggerError::Failed("receiver dropped".to_string())) diff --git a/crates/otter-core/src/triggers/polling.rs b/crates/otter-core/src/triggers/polling.rs index 57a63a6..9a7e1a2 100644 --- a/crates/otter-core/src/triggers/polling.rs +++ b/crates/otter-core/src/triggers/polling.rs @@ -238,6 +238,7 @@ impl PollingTrigger { hash: hash.clone(), secrets: self.poll_secrets.clone(), }), + inline_context: None, }; info!("sending trigger event for hash {}", hash); diff --git a/crates/otter-core/src/types.rs b/crates/otter-core/src/types.rs index 462202c..ad9cea1 100644 --- a/crates/otter-core/src/types.rs +++ b/crates/otter-core/src/types.rs @@ -90,6 +90,9 @@ pub enum TriggerDef { #[serde(default)] requires: Option>, }, + /// Fires only when another workflow hands it a run via `otter dispatch`. + /// The dispatched event carries a payload and a pre-built `trigger-context/`. + Dispatch, } fn default_poll_interval() -> u64 { @@ -178,6 +181,11 @@ pub struct TriggerEvent { /// Context command to run at the start of the workflow run, after the workspace is set up. /// When set, `run_once()` invokes `command ` before executing steps. pub pending_context: Option, + /// Files to materialize directly into `trigger-context/` before steps run, as + /// `(filename, contents)` pairs. This is the inline counterpart to + /// `pending_context`'s command form, used by the `dispatch` trigger to hand a + /// pre-built context to a started run. + pub inline_context: Option>, } #[derive(Debug, thiserror::Error)] @@ -521,6 +529,15 @@ pub enum DaemonCommand { Start { name: String, }, + /// Hand a one-off run to a `dispatch`-triggered workflow, carrying a payload + /// and a set of `(filename, contents)` files to pre-populate `trigger-context/`. + Dispatch { + workflow: String, + #[serde(default)] + payload: Option, + #[serde(default)] + context_files: Vec<(String, String)>, + }, Stop { name: String, }, diff --git a/crates/otter-core/src/workflow_manager.rs b/crates/otter-core/src/workflow_manager.rs index d38f808..a4a6ff5 100644 --- a/crates/otter-core/src/workflow_manager.rs +++ b/crates/otter-core/src/workflow_manager.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use crate::engine::Engine; +use crate::triggers::{DispatchMsg, DispatchRegistry}; use crate::types::{EngineEvent, StorageBackend, WorkflowDef, WorkflowState, WorkflowStatus}; use otter_notify::Notifier; use otter_secrets::{NoOpSecretStore, SecretStore}; @@ -28,6 +29,7 @@ pub struct WorkflowManager { data_dir: PathBuf, notifier: Arc, secret_store: Arc, + dispatch_registry: DispatchRegistry, } impl WorkflowManager { @@ -44,6 +46,7 @@ impl WorkflowManager { data_dir, notifier, secret_store: Arc::new(NoOpSecretStore), + dispatch_registry: Arc::new(Mutex::new(HashMap::new())), } } @@ -61,6 +64,7 @@ impl WorkflowManager { data_dir, notifier, secret_store, + dispatch_registry: Arc::new(Mutex::new(HashMap::new())), } } @@ -192,7 +196,8 @@ impl WorkflowManager { scripts_dir, self.secret_store.clone(), ) - .with_requirements(requirements); + .with_requirements(requirements) + .with_dispatch_registry(Some(self.dispatch_registry.clone())); let handle = self.handles.get_mut(name).unwrap(); let shutdown = handle.shutdown.clone(); @@ -221,6 +226,48 @@ impl WorkflowManager { Ok(()) } + /// Hand a one-off run to a running `dispatch`-triggered workflow. The target + /// must be started (its engine registers the inbox on start) and use + /// `trigger.type = "dispatch"`. + pub async fn dispatch( + &self, + workflow: &str, + payload: Option, + context_files: Vec<(String, String)>, + ) -> anyhow::Result<()> { + let not_accepting = || { + anyhow::anyhow!( + "workflow '{}' is not accepting dispatches (not running, or not a dispatch trigger)", + workflow + ) + }; + + let sender = { + let registry = self + .dispatch_registry + .lock() + .map_err(|_| anyhow::anyhow!("dispatch registry poisoned"))?; + registry.get(workflow).cloned().ok_or_else(not_accepting)? + }; + + if sender + .send(DispatchMsg { + payload: payload.unwrap_or_default(), + context_files, + }) + .await + .is_err() + { + // The inbox is gone (workflow stopped); drop the stale registration so + // future dispatches get the actionable "not accepting" error up front. + if let Ok(mut registry) = self.dispatch_registry.lock() { + registry.remove(workflow); + } + return Err(not_accepting()); + } + Ok(()) + } + /// Stop a running workflow gracefully. Awaits the engine task before returning. pub async fn stop(&mut self, name: &str) -> anyhow::Result<()> { let handle = self diff --git a/crates/otter-core/src/workflow_manager_tests.rs b/crates/otter-core/src/workflow_manager_tests.rs index ac8b05a..c7d5eea 100644 --- a/crates/otter-core/src/workflow_manager_tests.rs +++ b/crates/otter-core/src/workflow_manager_tests.rs @@ -97,6 +97,95 @@ fn polling_workflow(name: &str, command: Vec) -> WorkflowDef { } } +fn dispatch_workflow(name: &str, command: Vec) -> WorkflowDef { + WorkflowDef { + name: name.to_string(), + workflow_type: WorkflowType::Triggered, + schema: None, + version: None, + description: None, + trigger: Some(TriggerDef::Dispatch), + workspace: None, + resources: None, + sandbox: None, + steps: vec![StepDef { + command: Some(command), + ..shell_step() + }], + finally: vec![], + require: None, + } +} + +#[tokio::test] +async fn dispatch_runs_workflow_with_inline_context() { + // GIVEN a started dispatch-triggered workflow whose only step fails unless the + // dispatched context file was written into trigger-context/ + let temp_dir = std::env::temp_dir().join(format!( + "otter-dispatch-{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let (tx, _rx) = mpsc::channel(64); + let storage = Arc::new(InMemoryStorage::new()); + let mut manager = + WorkflowManager::new(storage.clone(), temp_dir.clone(), tx, Arc::new(NoOpNotifier)); + + let wf = dispatch_workflow( + "handler", + vec![ + "bash".to_string(), + "-c".to_string(), + "test -f trigger-context/summary.txt".to_string(), + ], + ); + manager.register(wf, String::new()); + manager.start("handler").await.unwrap(); + + // WHEN it is dispatched with a context file (retry until the engine task has + // registered the inbox) + let mut dispatched = false; + for _ in 0..100 { + if manager + .dispatch( + "handler", + Some("change-42".to_string()), + vec![("summary.txt".to_string(), "Change: 42".to_string())], + ) + .await + .is_ok() + { + dispatched = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(dispatched, "dispatch should reach the started workflow"); + + // THEN a run executes and completes (the context file was present) + let mut runs = storage.runs(); + for _ in 0..50 { + if !runs.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + runs = storage.runs(); + } + assert_eq!(runs.len(), 1); + assert_eq!( + runs[0].status, + crate::types::RunStatus::Completed, + "the dispatched context file should have been present" + ); + + manager.stop("handler").await.unwrap(); + let _ = std::fs::remove_dir_all(&temp_dir); +} + #[test] fn register_makes_workflow_dormant() { // GIVEN diff --git a/crates/otter-tui/src/panels/right_panel.rs b/crates/otter-tui/src/panels/right_panel.rs index c4cb76e..89c27cc 100644 --- a/crates/otter-tui/src/panels/right_panel.rs +++ b/crates/otter-tui/src/panels/right_panel.rs @@ -733,6 +733,12 @@ where ))); } } + Some(otter_core::types::TriggerDef::Dispatch) => { + lines.push(Line::from(Span::styled( + " dispatch (started by another workflow)".to_string(), + base_style(), + ))); + } } // WORKSPACE diff --git a/examples/dispatch-handler/README.md b/examples/dispatch-handler/README.md new file mode 100644 index 0000000..b81590c --- /dev/null +++ b/examples/dispatch-handler/README.md @@ -0,0 +1,27 @@ +# dispatch-handler + +A minimal `dispatch`-triggered workflow. It never polls and never fires on its +own — it runs only when another workflow (or you, from the CLI) hands it a run +with `otter dispatch`, passing a payload and files that pre-populate the run's +`trigger-context/`. + +## Try it + +```bash +otter workflow install examples/dispatch-handler +otter service start +otter start dispatch-handler # bring it up so it can receive dispatches + +# In another shell, hand it a run with some context: +echo "Change: 42 — fix the flaky test" > /tmp/summary.txt +otter dispatch dispatch-handler --payload "change-42" --context-file summary.txt=/tmp/summary.txt +``` + +The handler's first step prints the `summary.txt` it received. + +## When to use a dispatch trigger + +Use `type = "dispatch"` when one workflow needs to start another **with data** — +for example a router/dispatcher workflow that parses an instruction and hands the +relevant context to a specialized handler. See the +[Triggers → dispatch](../../USAGE.md#dispatch) section of the usage guide. diff --git a/examples/dispatch-handler/workflow.toml b/examples/dispatch-handler/workflow.toml new file mode 100644 index 0000000..5b27157 --- /dev/null +++ b/examples/dispatch-handler/workflow.toml @@ -0,0 +1,21 @@ +name = "dispatch-handler" +type = "triggered" +schema = 1 +version = "0.1.0" +description = "An on-demand handler started by another workflow via `otter dispatch`." + +# A dispatch trigger never fires on its own. The workflow sits idle (once started +# or enabled) until something runs `otter dispatch dispatch-handler ...`, which +# hands it a payload and a pre-populated trigger-context/ directory. +[trigger] +type = "dispatch" + +# Echo whatever context the dispatcher handed over. With no [workspace], steps run +# in the run's scratch dir, where trigger-context/ is created. +[[steps]] +type = "shell" +command = ["sh", "-c", "echo 'Dispatched run:'; cat trigger-context/summary.txt 2>/dev/null || echo '(no summary.txt provided)'"] + +[[steps]] +type = "notify" +message = "dispatch-handler ran from a handed-over context."