Skip to content
Open
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
152 changes: 126 additions & 26 deletions crates/cli/src/commands/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub enum TriggerType {
Manual,
/// Agent idle trigger — fires after the agent is idle for N seconds.
AgentIdle,
/// Filesystem change trigger — fires when files under watched paths change.
FileWatch,
/// Linear issues trigger — polls Linear for matching issues.
LinearIssues,
}
Expand Down Expand Up @@ -696,6 +698,30 @@ pub enum OrchestratorCommand {
#[arg(long)]
idle_seconds: Option<u64>,

/// Paths to watch for filesystem changes — file-watch trigger only (repeatable)
#[arg(long = "watch-path", value_name = "PATH")]
watch_paths: Vec<String>,

/// Glob patterns to filter watched paths — file-watch trigger only (repeatable)
#[arg(long = "watch-pattern", value_name = "PATTERN")]
watch_patterns: Vec<String>,

/// Event kinds to watch for: create, modify, delete, access — file-watch trigger only (repeatable)
#[arg(long = "watch-event", value_name = "EVENT")]
watch_events: Vec<String>,

/// Debounce window in milliseconds — file-watch trigger only (default: 200)
#[arg(long, default_value = "200")]
debounce_ms: u64,

/// Watch backend: native, polling, or auto — file-watch trigger only (default: auto)
#[arg(long = "watch-mode", default_value = "auto")]
watch_mode: String,

/// Polling interval in seconds for file-watch polling mode (default: 5)
#[arg(long = "watch-poll-interval", default_value = "5")]
watch_poll_interval: u64,

/// Prompt template with {{placeholders}} (e.g. "Fix: {{title}}\n{{body}}")
#[arg(long, conflicts_with = "prompt_template_file")]
prompt_template: Option<String>,
Expand Down Expand Up @@ -935,6 +961,12 @@ impl OrchestratorCommand {
linear_labels,
linear_assignee,
idle_seconds,
watch_paths,
watch_patterns,
watch_events,
debounce_ms,
watch_mode,
watch_poll_interval,
prompt_template,
prompt_template_file,
poll_interval,
Expand All @@ -959,6 +991,12 @@ impl OrchestratorCommand {
linear_labels,
linear_assignee.as_deref(),
*idle_seconds,
watch_paths,
watch_patterns,
watch_events,
*debounce_ms,
watch_mode,
*watch_poll_interval,
prompt_template.as_deref(),
prompt_template_file.as_deref(),
*poll_interval,
Expand Down Expand Up @@ -2279,6 +2317,12 @@ async fn create_workflow(
linear_labels: &[String],
linear_assignee: Option<&str>,
idle_seconds: Option<u64>,
watch_paths: &[String],
watch_patterns: &[String],
watch_events: &[String],
debounce_ms: u64,
watch_mode: &str,
watch_poll_interval: u64,
prompt_template: Option<&str>,
prompt_template_file: Option<&std::path::Path>,
poll_interval: u64,
Expand Down Expand Up @@ -2349,6 +2393,23 @@ async fn create_workflow(
}
TriggerConfig::AgentIdle { idle_seconds: secs }
}
TriggerType::FileWatch => {
if watch_paths.is_empty() {
bail!("--watch-path is required for file-watch trigger (use --watch-path PATH)");
}
let valid_modes = ["native", "polling", "auto"];
if !valid_modes.contains(&watch_mode) {
bail!("--watch-mode must be one of: native, polling, auto (got '{}')", watch_mode);
}
TriggerConfig::FileWatch {
paths: watch_paths.to_vec(),
patterns: watch_patterns.to_vec(),
events: watch_events.to_vec(),
debounce_ms,
mode: watch_mode.to_string(),
poll_interval_secs: watch_poll_interval,
}
}
TriggerType::LinearIssues => {
// At least one filter must be provided (validated server-side too,
// but catch obvious mistakes early with a helpful message).
Expand Down Expand Up @@ -2723,6 +2784,27 @@ fn display_workflow(workflow: &WorkflowResponse) {
TriggerConfig::AgentIdle { idle_seconds } => {
println!("{}: {}s", "Idle Timeout".bold(), idle_seconds);
}
TriggerConfig::FileWatch {
paths,
patterns,
events,
debounce_ms,
mode,
poll_interval_secs,
} => {
println!("{}: {}", "Paths".bold(), paths.join(", "));
if !patterns.is_empty() {
println!("{}: {}", "Patterns".bold(), patterns.join(", "));
}
if !events.is_empty() {
println!("{}: {}", "Events".bold(), events.join(", "));
}
println!("{}: {}ms", "Debounce".bold(), debounce_ms);
println!("{}: {}", "Watch Mode".bold(), mode);
if mode == "polling" || mode == "auto" {
println!("{}: {}s", "Poll Interval".bold(), poll_interval_secs);
}
}
TriggerConfig::LinearIssues { team_key, project, status, labels, assignee } => {
if let Some(tk) = team_key {
println!("{}: {}", "Team Key".bold(), tk);
Expand Down Expand Up @@ -4049,19 +4131,25 @@ mod tests {
Some("550e8400-e29b-41d4-a716-446655440000"),
None,
&TriggerType::LinearIssues,
None, // owner
None, // repo
None, // labels
None, // state
None, // cron_expression
None, // run_at
None, // webhook_secret
None, // team_key
None, // linear_project
&[], // linear_status
&[], // linear_labels
None, // linear_assignee
None, // idle_seconds
None, // owner
None, // repo
None, // labels
None, // state
None, // cron_expression
None, // run_at
None, // webhook_secret
None, // team_key
None, // linear_project
&[], // linear_status
&[], // linear_labels
None, // linear_assignee
None, // idle_seconds
&[], // watch_paths
&[], // watch_patterns
&[], // watch_events
200, // debounce_ms
"auto", // watch_mode
5, // watch_poll_interval
Some("Fix: {{title}}"),
None, // prompt_template_file
60,
Expand Down Expand Up @@ -4120,19 +4208,25 @@ mod tests {
Some("550e8400-e29b-41d4-a716-446655440000"),
None,
&TriggerType::AgentIdle,
None, // owner
None, // repo
None, // labels
None, // state
None, // cron_expression
None, // run_at
None, // webhook_secret
None, // team_key
None, // linear_project
&[], // linear_status
&[], // linear_labels
None, // linear_assignee
None, // idle_seconds — missing!
None, // owner
None, // repo
None, // labels
None, // state
None, // cron_expression
None, // run_at
None, // webhook_secret
None, // team_key
None, // linear_project
&[], // linear_status
&[], // linear_labels
None, // linear_assignee
None, // idle_seconds — missing!
&[], // watch_paths
&[], // watch_patterns
&[], // watch_events
200, // debounce_ms
"auto", // watch_mode
5, // watch_poll_interval
Some("Do background work"),
None, // prompt_template_file
60,
Expand Down Expand Up @@ -4169,6 +4263,12 @@ mod tests {
&[], // linear_labels
None, // linear_assignee
Some(0), // idle_seconds = 0 (invalid)
&[], // watch_paths
&[], // watch_patterns
&[], // watch_events
200, // debounce_ms
"auto", // watch_mode
5, // watch_poll_interval
Some("Do background work"),
None, // prompt_template_file
60,
Expand Down
24 changes: 24 additions & 0 deletions crates/orchestrator/src/scheduler/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ async fn create_workflow(
));
}
}
TriggerConfig::FileWatch { paths, mode, poll_interval_secs, .. } => {
if paths.is_empty() {
return Err(ApiError::InvalidInput(
"FileWatch trigger requires at least one path".to_string(),
));
}
if paths.iter().any(|p| p.trim().is_empty()) {
return Err(ApiError::InvalidInput(
"FileWatch trigger paths must not be empty strings".to_string(),
));
}
let valid_modes = ["native", "polling", "auto"];
if !valid_modes.contains(&mode.as_str()) {
return Err(ApiError::InvalidInput(format!(
"FileWatch mode must be one of: native, polling, auto (got '{}')",
mode
)));
}
if mode == "polling" && *poll_interval_secs == 0 {
return Err(ApiError::InvalidInput(
"FileWatch polling mode requires 'poll_interval_secs' > 0".to_string(),
));
}
}
TriggerConfig::LinearIssues { team_key, project, status, labels, assignee } => {
// Require at least one filter so the scheduler does not poll the
// entire Linear workspace indiscriminately. All filter fields are
Expand Down
28 changes: 26 additions & 2 deletions crates/orchestrator/src/scheduler/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use crate::scheduler::linear::LinearIssueSource;
use crate::scheduler::source::TaskSource;
use crate::scheduler::storage::SchedulerStorage;
use crate::scheduler::strategy::{
CronStrategy, DelayStrategy, EventFilter, EventStrategy, IdleStrategy, PollingStrategy,
TriggerStrategy,
CronStrategy, DelayStrategy, EventFilter, EventStrategy, FileWatchStrategy, IdleStrategy,
PollingStrategy, TriggerStrategy, WatchMode,
};
use crate::scheduler::template::render_template;
use crate::scheduler::types::{
Expand Down Expand Up @@ -342,6 +342,30 @@ pub fn create_strategy(
let duration = std::time::Duration::from_secs(*idle_seconds);
Ok(Box::new(IdleStrategy::new(bus.clone(), config.agent_id, duration)))
}
TriggerConfig::FileWatch {
paths,
patterns,
events,
debounce_ms,
mode,
poll_interval_secs,
} => {
let watch_paths: Vec<std::path::PathBuf> =
paths.iter().map(std::path::PathBuf::from).collect();
let pats = if patterns.is_empty() { None } else { Some(patterns.clone()) };
let watch_mode = match mode.as_str() {
"native" => WatchMode::Native,
"polling" => WatchMode::Polling { interval_secs: *poll_interval_secs },
_ => WatchMode::Auto { poll_interval_secs: *poll_interval_secs },
};
Ok(Box::new(FileWatchStrategy::new(
watch_paths,
pats,
events.clone(),
*debounce_ms,
watch_mode,
)?))
}
_ => {
let source = create_source(&config.trigger_config)?;
Ok(Box::new(PollingStrategy::new(source, config.poll_interval_secs)))
Expand Down
9 changes: 9 additions & 0 deletions crates/orchestrator/src/scheduler/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ use crate::scheduler::types::Task;
/// | `status` | dispatch_result | Completion status (`completed` or `failed`) |
/// | `timestamp` | dispatch_result | RFC 3339 timestamp of the completion event |
/// | `original_source_id` | dispatch_result | Source ID from the parent dispatch (if any) |
/// | `file_path` | file_watch | Full path of the changed file |
/// | `file_name` | file_watch | Basename of the changed file |
/// | `file_dir` | file_watch | Parent directory of the changed file |
/// | `event_type` | file_watch | Event kind: `"create"`, `"modify"`, `"delete"` |
/// | `action` | webhook (GitHub/Linear)| Event action (e.g., `"opened"`, `"create"`) |
/// | `github_event` | webhook (GitHub) | GitHub event type (e.g., `"issues"`) |
/// | `delivery_id` | webhook (GitHub) | GitHub delivery UUID (`X-GitHub-Delivery`) |
Expand Down Expand Up @@ -55,6 +59,11 @@ pub const KNOWN_VARIABLES: &[&str] = &[
"status",
"timestamp",
"original_source_id",
// Metadata-backed (file_watch trigger)
"file_path",
"file_name",
"file_dir",
"event_type",
// Metadata-backed (webhook triggers — GitHub)
// Note: `action` is also used by Linear webhooks as `linear_action`; the
// GitHub `action` key and the Linear `linear_action` key are kept separate
Expand Down
47 changes: 47 additions & 0 deletions crates/orchestrator/src/scheduler/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ pub enum TriggerConfig {
/// How many seconds of inactivity trigger the workflow.
idle_seconds: u64,
},
/// Filesystem change trigger (Phase 7).
///
/// Fires when a file under a watched path changes. Uses the OS-native
/// filesystem event API (inotify/FSEvents/kqueue) by default, with an
/// optional mtime-polling fallback.
///
/// # Fields
///
/// - `paths` — directories or files to watch recursively (required).
/// - `patterns` — optional glob patterns restricting which paths produce tasks.
/// - `events` — event kinds to accept: `"create"`, `"modify"`, `"delete"`, `"access"`. Empty = all.
/// - `debounce_ms` — debounce window in milliseconds (default: 200).
/// - `mode` — watch backend: `"native"` | `"polling"` | `"auto"` (default: `"auto"`).
/// - `poll_interval_secs` — polling interval when mode is `"polling"` (default: 5).
FileWatch {
/// Paths to watch recursively (required, at least one).
paths: Vec<String>,
/// Optional glob patterns. Empty = watch all files.
#[serde(default)]
patterns: Vec<String>,
/// Event kinds to accept. Empty = all kinds.
#[serde(default)]
events: Vec<String>,
/// Debounce window in milliseconds.
#[serde(default = "default_file_watch_debounce_ms")]
debounce_ms: u64,
/// Watch backend: `"native"`, `"polling"`, or `"auto"`.
#[serde(default = "default_file_watch_mode")]
mode: String,
/// Polling interval in seconds when mode is `"polling"` or auto-fallback.
#[serde(default = "default_file_watch_poll_interval")]
poll_interval_secs: u64,
},
/// Linear issues trigger — polls Linear for issues matching the given filters.
///
/// Requires `AGENTD_LINEAR_API_KEY` to be set in the environment.
Expand Down Expand Up @@ -184,6 +217,18 @@ fn default_pr_state() -> String {
"open".to_string()
}

fn default_file_watch_debounce_ms() -> u64 {
200
}

fn default_file_watch_mode() -> String {
"auto".to_string()
}

fn default_file_watch_poll_interval() -> u64 {
5
}

impl TriggerConfig {
pub fn trigger_type(&self) -> &'static str {
match self {
Expand All @@ -196,6 +241,7 @@ impl TriggerConfig {
TriggerConfig::Webhook { .. } => "webhook",
TriggerConfig::Manual { .. } => "manual",
TriggerConfig::AgentIdle { .. } => "agent_idle",
TriggerConfig::FileWatch { .. } => "file_watch",
TriggerConfig::LinearIssues { .. } => "linear_issues",
}
}
Expand All @@ -212,6 +258,7 @@ impl TriggerConfig {
| TriggerConfig::Webhook { .. }
| TriggerConfig::Manual { .. }
| TriggerConfig::AgentIdle { .. }
| TriggerConfig::FileWatch { .. }
| TriggerConfig::LinearIssues { .. } => true,
}
}
Expand Down