From b4d43c4c8535401d4763ee80916834edade444b8 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Sat, 28 Mar 2026 11:37:58 -0700 Subject: [PATCH 1/2] feat: add FileWatch TriggerConfig variant and wire into factory/API/CLI From 2ebc08a964e94a399960686f37d32bc7464614de Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Sat, 28 Mar 2026 11:52:06 -0700 Subject: [PATCH 2/2] feat(scheduler): add FileWatch TriggerConfig variant and wire into factory/API/CLI/templates --- crates/cli/src/commands/orchestrator.rs | 152 +++++++++++++++--- crates/orchestrator/src/scheduler/api.rs | 24 +++ crates/orchestrator/src/scheduler/runner.rs | 28 +++- crates/orchestrator/src/scheduler/template.rs | 9 ++ crates/orchestrator/src/scheduler/types.rs | 47 ++++++ 5 files changed, 232 insertions(+), 28 deletions(-) diff --git a/crates/cli/src/commands/orchestrator.rs b/crates/cli/src/commands/orchestrator.rs index ce9f6780..c103910e 100644 --- a/crates/cli/src/commands/orchestrator.rs +++ b/crates/cli/src/commands/orchestrator.rs @@ -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, } @@ -696,6 +698,30 @@ pub enum OrchestratorCommand { #[arg(long)] idle_seconds: Option, + /// Paths to watch for filesystem changes — file-watch trigger only (repeatable) + #[arg(long = "watch-path", value_name = "PATH")] + watch_paths: Vec, + + /// Glob patterns to filter watched paths — file-watch trigger only (repeatable) + #[arg(long = "watch-pattern", value_name = "PATTERN")] + watch_patterns: Vec, + + /// Event kinds to watch for: create, modify, delete, access — file-watch trigger only (repeatable) + #[arg(long = "watch-event", value_name = "EVENT")] + watch_events: Vec, + + /// 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, @@ -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, @@ -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, @@ -2279,6 +2317,12 @@ async fn create_workflow( linear_labels: &[String], linear_assignee: Option<&str>, idle_seconds: Option, + 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, @@ -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). @@ -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); @@ -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, @@ -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, @@ -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, diff --git a/crates/orchestrator/src/scheduler/api.rs b/crates/orchestrator/src/scheduler/api.rs index 13dbe820..4eb53837 100644 --- a/crates/orchestrator/src/scheduler/api.rs +++ b/crates/orchestrator/src/scheduler/api.rs @@ -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 diff --git a/crates/orchestrator/src/scheduler/runner.rs b/crates/orchestrator/src/scheduler/runner.rs index 3113fa59..a304fa50 100644 --- a/crates/orchestrator/src/scheduler/runner.rs +++ b/crates/orchestrator/src/scheduler/runner.rs @@ -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::{ @@ -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 = + 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))) diff --git a/crates/orchestrator/src/scheduler/template.rs b/crates/orchestrator/src/scheduler/template.rs index e37c919f..3043f49d 100644 --- a/crates/orchestrator/src/scheduler/template.rs +++ b/crates/orchestrator/src/scheduler/template.rs @@ -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`) | @@ -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 diff --git a/crates/orchestrator/src/scheduler/types.rs b/crates/orchestrator/src/scheduler/types.rs index 0c6f4501..4249c7ff 100644 --- a/crates/orchestrator/src/scheduler/types.rs +++ b/crates/orchestrator/src/scheduler/types.rs @@ -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, + /// Optional glob patterns. Empty = watch all files. + #[serde(default)] + patterns: Vec, + /// Event kinds to accept. Empty = all kinds. + #[serde(default)] + events: Vec, + /// 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. @@ -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 { @@ -196,6 +241,7 @@ impl TriggerConfig { TriggerConfig::Webhook { .. } => "webhook", TriggerConfig::Manual { .. } => "manual", TriggerConfig::AgentIdle { .. } => "agent_idle", + TriggerConfig::FileWatch { .. } => "file_watch", TriggerConfig::LinearIssues { .. } => "linear_issues", } } @@ -212,6 +258,7 @@ impl TriggerConfig { | TriggerConfig::Webhook { .. } | TriggerConfig::Manual { .. } | TriggerConfig::AgentIdle { .. } + | TriggerConfig::FileWatch { .. } | TriggerConfig::LinearIssues { .. } => true, } }