Skip to content

Commit 3042563

Browse files
authored
Merge pull request #522 from stakpak/feat/trigger-check-customization
Add customizable check logic & prompt improvements
2 parents c99dd3a + 206d3cf commit 3042563

7 files changed

Lines changed: 339 additions & 49 deletions

File tree

cli/src/commands/watch/commands/init.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ timeout = "30m"
5959
# Maximum time for check scripts (default: 30s)
6060
check_timeout = "30s"
6161
62+
# Determines which check script exit codes trigger the agent (default: "success")
63+
# - "success": trigger on exit 0
64+
# - "failure": trigger on non-zero exit codes (1+)
65+
# - "any": trigger regardless of exit code (only timeout/error prevents trigger)
66+
# check_trigger_on = "success"
67+
6268
# Enable Slack tools for agent (experimental, default: false)
6369
# enable_slack_tools = false
6470
@@ -85,9 +91,14 @@ Output a summary report but DO NOT make any changes to the system.
8591
This is a read-only health check.
8692
"""
8793
# Optional: check script that determines if agent should run
88-
# Exit 0 = run agent, Exit 1 = skip, Exit 2+ = error
8994
# check = "~/.stakpak/triggers/check-weekday.sh"
9095
96+
# Optional: which exit codes trigger the agent (overrides default)
97+
# - "success": trigger on exit 0 (default)
98+
# - "failure": trigger on non-zero exit codes (1+)
99+
# - "any": trigger regardless of exit code
100+
# check_trigger_on = "success"
101+
91102
# Optional: override default timeout for this trigger
92103
# timeout = "10m"
93104
@@ -112,6 +123,20 @@ This is a read-only health check.
112123
# DO NOT make any changes, just report findings.
113124
# """
114125
126+
# Example: Alert on service failure (trigger on failure)
127+
# [[triggers]]
128+
# name = "service-down-alert"
129+
# schedule = "*/5 * * * *" # Every 5 minutes
130+
# check = "~/.stakpak/triggers/check-service-health.sh"
131+
# check_trigger_on = "failure" # Only wake agent when check fails
132+
# prompt = """
133+
# The service health check failed. Investigate and report:
134+
# - Which services are down
135+
# - Recent error logs
136+
# - Possible root causes
137+
# DO NOT restart services automatically, just report findings.
138+
# """
139+
115140
# Example: Security advisory check (read-only)
116141
# [[triggers]]
117142
# name = "security-advisories"

cli/src/commands/watch/commands/run.rs

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -278,38 +278,39 @@ async fn handle_trigger_event(
278278
return Ok(());
279279
}
280280

281-
if result.failed() {
282-
warn!(
281+
// Determine if we should trigger based on check_trigger_on setting
282+
let exit_code = result.exit_code.unwrap_or(-1);
283+
let check_trigger_on = trigger.effective_check_trigger_on(&config.defaults);
284+
let should_trigger = check_trigger_on.should_trigger(exit_code);
285+
286+
if !should_trigger {
287+
info!(
283288
trigger = %trigger.name,
284-
exit_code = ?result.exit_code,
285-
"Check script failed"
289+
exit_code = exit_code,
290+
check_trigger_on = %check_trigger_on,
291+
"Check script did not meet trigger condition"
286292
);
287293
print_event(
288-
"fail",
294+
"skip",
289295
&trigger.name,
290-
&format!("Check failed (exit {})", result.exit_code.unwrap_or(-1)),
296+
&format!(
297+
"Skipped (exit {} does not match trigger_on={})",
298+
exit_code, check_trigger_on
299+
),
291300
);
292-
db.update_run_finished(
293-
run_id,
294-
RunStatus::Failed,
295-
Some("Check script failed"),
296-
None,
297-
None,
298-
)
299-
.await
300-
.map_err(|e| format!("Failed to update run status: {}", e))?;
301-
return Ok(());
302-
}
303-
304-
if result.skipped() {
305-
info!(trigger = %trigger.name, "Check script returned skip (exit 1)");
306-
print_event("skip", &trigger.name, "Skipped (check returned exit 1)");
307301
db.update_run_finished(run_id, RunStatus::Skipped, None, None, None)
308302
.await
309303
.map_err(|e| format!("Failed to update run status: {}", e))?;
310304
return Ok(());
311305
}
312306

307+
info!(
308+
trigger = %trigger.name,
309+
exit_code = exit_code,
310+
check_trigger_on = %check_trigger_on,
311+
"Check script met trigger condition"
312+
);
313+
313314
Some(result)
314315
}
315316
Err(e) => {

cli/src/commands/watch/commands/trigger.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,21 @@ pub async fn fire_trigger(name: &str, dry_run: bool) -> Result<(), String> {
147147

148148
if result.timed_out {
149149
println!("\n\x1b[31mCheck script timed out\x1b[0m");
150-
} else if result.failed() {
151-
println!("\n\x1b[31mCheck script failed (exit {})\x1b[0m", exit_code);
152-
} else if result.skipped() {
153-
println!(
154-
"\n\x1b[33mCheck script returned skip (exit 1) - agent would not be woken\x1b[0m"
155-
);
150+
} else {
151+
let check_trigger_on = trigger.effective_check_trigger_on(&config.defaults);
152+
let should_trigger = check_trigger_on.should_trigger(exit_code);
153+
println!("Check trigger_on: {}", check_trigger_on);
154+
if should_trigger {
155+
println!(
156+
"\n\x1b[32mCheck passed (exit {} matches trigger_on={}) - agent would be woken\x1b[0m",
157+
exit_code, check_trigger_on
158+
);
159+
} else {
160+
println!(
161+
"\n\x1b[33mCheck skipped (exit {} does not match trigger_on={}) - agent would not be woken\x1b[0m",
162+
exit_code, check_trigger_on
163+
);
164+
}
156165
}
157166

158167
Some(result)

cli/src/commands/watch/config.rs

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,40 @@ fn default_log_dir() -> String {
5757
"~/.stakpak/watch/logs".to_string()
5858
}
5959

60+
/// Determines which check script exit codes trigger the agent.
61+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62+
#[serde(rename_all = "lowercase")]
63+
pub enum CheckTriggerOn {
64+
/// Trigger agent only on exit code 0 (default behavior).
65+
#[default]
66+
Success,
67+
/// Trigger agent on any non-zero exit code (1+).
68+
Failure,
69+
/// Trigger agent regardless of exit code (only timeout/error prevents trigger).
70+
Any,
71+
}
72+
73+
impl CheckTriggerOn {
74+
/// Returns true if the given exit code should trigger the agent.
75+
pub fn should_trigger(&self, exit_code: i32) -> bool {
76+
match self {
77+
CheckTriggerOn::Success => exit_code == 0,
78+
CheckTriggerOn::Failure => exit_code != 0,
79+
CheckTriggerOn::Any => true,
80+
}
81+
}
82+
}
83+
84+
impl std::fmt::Display for CheckTriggerOn {
85+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86+
match self {
87+
CheckTriggerOn::Success => write!(f, "success"),
88+
CheckTriggerOn::Failure => write!(f, "failure"),
89+
CheckTriggerOn::Any => write!(f, "any"),
90+
}
91+
}
92+
}
93+
6094
/// Default values applied to triggers when not specified.
6195
#[derive(Debug, Clone, Serialize, Deserialize)]
6296
pub struct WatchDefaults {
@@ -84,6 +118,13 @@ pub struct WatchDefaults {
84118
/// When true, the agent will pause and exit with code 10 when tools need approval.
85119
#[serde(default = "default_pause_on_approval")]
86120
pub pause_on_approval: bool,
121+
122+
/// Determines which check script exit codes trigger the agent.
123+
/// - "success" (default): trigger on exit 0
124+
/// - "failure": trigger on non-zero exit codes (1+)
125+
/// - "any": trigger regardless of exit code
126+
#[serde(default)]
127+
pub check_trigger_on: CheckTriggerOn,
87128
}
88129

89130
impl Default for WatchDefaults {
@@ -95,6 +136,7 @@ impl Default for WatchDefaults {
95136
enable_slack_tools: false,
96137
enable_subagents: false,
97138
pause_on_approval: default_pause_on_approval(),
139+
check_trigger_on: CheckTriggerOn::default(),
98140
}
99141
}
100142
}
@@ -133,6 +175,13 @@ pub struct Trigger {
133175
#[serde(default, with = "option_humantime_serde")]
134176
pub check_timeout: Option<Duration>,
135177

178+
/// Determines which check script exit codes trigger the agent.
179+
/// Falls back to defaults.check_trigger_on if not specified.
180+
/// - "success" (default): trigger on exit 0
181+
/// - "failure": trigger on non-zero exit codes (1+)
182+
/// - "any": trigger regardless of exit code
183+
pub check_trigger_on: Option<CheckTriggerOn>,
184+
136185
/// Prompt to pass to the agent when triggered.
137186
pub prompt: String,
138187

@@ -177,6 +226,11 @@ impl Trigger {
177226
self.check_timeout.unwrap_or(defaults.check_timeout)
178227
}
179228

229+
/// Get the effective check_trigger_on, falling back to defaults.
230+
pub fn effective_check_trigger_on(&self, defaults: &WatchDefaults) -> CheckTriggerOn {
231+
self.check_trigger_on.unwrap_or(defaults.check_trigger_on)
232+
}
233+
180234
/// Get the effective enable_slack_tools, falling back to defaults.
181235
pub fn effective_enable_slack_tools(&self, defaults: &WatchDefaults) -> bool {
182236
self.enable_slack_tools
@@ -627,6 +681,134 @@ prompt = "Test"
627681
[[triggers]]
628682
name = "test"
629683
schedule = "0 * * * *"
684+
"#;
685+
686+
let result = WatchConfig::parse(config_str);
687+
assert!(result.is_err());
688+
assert!(matches!(result.unwrap_err(), ConfigError::ParseError(_)));
689+
}
690+
691+
#[test]
692+
fn test_check_trigger_on_should_trigger() {
693+
// Success mode: only exit 0 triggers
694+
assert!(CheckTriggerOn::Success.should_trigger(0));
695+
assert!(!CheckTriggerOn::Success.should_trigger(1));
696+
assert!(!CheckTriggerOn::Success.should_trigger(2));
697+
assert!(!CheckTriggerOn::Success.should_trigger(-1));
698+
699+
// Failure mode: any non-zero triggers
700+
assert!(!CheckTriggerOn::Failure.should_trigger(0));
701+
assert!(CheckTriggerOn::Failure.should_trigger(1));
702+
assert!(CheckTriggerOn::Failure.should_trigger(2));
703+
assert!(CheckTriggerOn::Failure.should_trigger(-1));
704+
705+
// Any mode: all exit codes trigger
706+
assert!(CheckTriggerOn::Any.should_trigger(0));
707+
assert!(CheckTriggerOn::Any.should_trigger(1));
708+
assert!(CheckTriggerOn::Any.should_trigger(2));
709+
assert!(CheckTriggerOn::Any.should_trigger(-1));
710+
}
711+
712+
#[test]
713+
fn test_check_trigger_on_default() {
714+
assert_eq!(CheckTriggerOn::default(), CheckTriggerOn::Success);
715+
}
716+
717+
#[test]
718+
fn test_check_trigger_on_display() {
719+
assert_eq!(CheckTriggerOn::Success.to_string(), "success");
720+
assert_eq!(CheckTriggerOn::Failure.to_string(), "failure");
721+
assert_eq!(CheckTriggerOn::Any.to_string(), "any");
722+
}
723+
724+
#[test]
725+
fn test_check_trigger_on_parsing() {
726+
// Test parsing from TOML
727+
let config_str = r#"
728+
[[triggers]]
729+
name = "success-trigger"
730+
schedule = "0 * * * *"
731+
prompt = "Test"
732+
check_trigger_on = "success"
733+
734+
[[triggers]]
735+
name = "failure-trigger"
736+
schedule = "0 * * * *"
737+
prompt = "Test"
738+
check_trigger_on = "failure"
739+
740+
[[triggers]]
741+
name = "any-trigger"
742+
schedule = "0 * * * *"
743+
prompt = "Test"
744+
check_trigger_on = "any"
745+
746+
[[triggers]]
747+
name = "default-trigger"
748+
schedule = "0 * * * *"
749+
prompt = "Test"
750+
"#;
751+
752+
let config = WatchConfig::parse(config_str).expect("Should parse check_trigger_on values");
753+
assert_eq!(config.triggers.len(), 4);
754+
755+
assert_eq!(
756+
config.triggers[0].check_trigger_on,
757+
Some(CheckTriggerOn::Success)
758+
);
759+
assert_eq!(
760+
config.triggers[1].check_trigger_on,
761+
Some(CheckTriggerOn::Failure)
762+
);
763+
assert_eq!(
764+
config.triggers[2].check_trigger_on,
765+
Some(CheckTriggerOn::Any)
766+
);
767+
assert_eq!(config.triggers[3].check_trigger_on, None);
768+
}
769+
770+
#[test]
771+
fn test_check_trigger_on_defaults_fallback() {
772+
let config_str = r#"
773+
[defaults]
774+
check_trigger_on = "failure"
775+
776+
[[triggers]]
777+
name = "uses-default"
778+
schedule = "0 * * * *"
779+
prompt = "Test"
780+
781+
[[triggers]]
782+
name = "overrides-default"
783+
schedule = "0 * * * *"
784+
prompt = "Test"
785+
check_trigger_on = "success"
786+
"#;
787+
788+
let config =
789+
WatchConfig::parse(config_str).expect("Should parse check_trigger_on with defaults");
790+
791+
// First trigger should use default (failure)
792+
assert_eq!(
793+
config.triggers[0].effective_check_trigger_on(&config.defaults),
794+
CheckTriggerOn::Failure
795+
);
796+
797+
// Second trigger should override to success
798+
assert_eq!(
799+
config.triggers[1].effective_check_trigger_on(&config.defaults),
800+
CheckTriggerOn::Success
801+
);
802+
}
803+
804+
#[test]
805+
fn test_check_trigger_on_invalid_value() {
806+
let config_str = r#"
807+
[[triggers]]
808+
name = "invalid"
809+
schedule = "0 * * * *"
810+
prompt = "Test"
811+
check_trigger_on = "invalid"
630812
"#;
631813

632814
let result = WatchConfig::parse(config_str);

0 commit comments

Comments
 (0)