@@ -2,6 +2,33 @@ use std::path::PathBuf;
22
33use serde:: { Deserialize , Serialize } ;
44
5+ /// Strategy for gating agent spawns.
6+ /// All strategies fail-open: if the check itself fails, the agent spawns anyway.
7+ #[ derive( Debug , Clone , Serialize , Deserialize , PartialEq ) ]
8+ #[ serde( tag = "kind" , rename_all = "kebab-case" ) ]
9+ pub enum PreCheckStrategy {
10+ /// Always spawn the agent. No gating.
11+ Always ,
12+ /// Check git diff between last recorded commit and HEAD.
13+ /// Only spawn if changed files match watch_paths prefixes.
14+ GitDiff { watch_paths : Vec < String > } ,
15+ /// Query latest comments on a Gitea issue. Skip if PASS verdict
16+ /// and no new commits since. (Phase 2 - not implemented yet)
17+ GiteaIssue { issue_number : u64 } ,
18+ /// Run an arbitrary shell command via sh -c.
19+ /// Exit 0 + non-empty stdout = Findings; Exit 0 + empty stdout = NoFindings;
20+ /// Non-zero exit or timeout = Failed (fail-open).
21+ Shell {
22+ script : String ,
23+ #[ serde( default = "default_pre_check_timeout" ) ]
24+ timeout_secs : u64 ,
25+ } ,
26+ }
27+
28+ fn default_pre_check_timeout ( ) -> u64 {
29+ 60
30+ }
31+
532/// Top-level orchestrator configuration (parsed from TOML).
633#[ derive( Debug , Clone , Serialize , Deserialize ) ]
734pub struct OrchestratorConfig {
@@ -91,6 +118,10 @@ pub struct AgentDefinition {
91118 /// Maximum CPU seconds allowed per agent execution.
92119 #[ serde( default ) ]
93120 pub max_cpu_seconds : Option < u64 > ,
121+ /// Optional pre-check strategy to gate agent spawns.
122+ /// If None, the agent always spawns (equivalent to Always).
123+ #[ serde( default ) ]
124+ pub pre_check : Option < PreCheckStrategy > ,
94125}
95126
96127/// Agent layer in the dark factory hierarchy.
@@ -372,6 +403,19 @@ impl OrchestratorConfig {
372403 }
373404 }
374405 }
406+
407+ // Validate pre-check strategies
408+ for agent in & self . agents {
409+ if let Some ( PreCheckStrategy :: GiteaIssue { .. } ) = & agent. pre_check {
410+ if self . workflow . is_none ( ) {
411+ return Err ( crate :: error:: OrchestratorError :: PreCheckConfig {
412+ agent : agent. name . clone ( ) ,
413+ reason : "gitea-issue strategy requires [workflow] config section" . into ( ) ,
414+ } ) ;
415+ }
416+ }
417+ }
418+
375419 Ok ( ( ) )
376420 }
377421}
@@ -1008,4 +1052,216 @@ task = "t"
10081052 assert ! ( config. agents. len( ) >= 3 ) ;
10091053 }
10101054 }
1055+
1056+ #[ test]
1057+ fn test_config_parse_pre_check_always ( ) {
1058+ let toml_str = r#"
1059+ working_dir = "/tmp"
1060+ [nightwatch]
1061+ [compound_review]
1062+ schedule = "0 0 * * *"
1063+ repo_path = "/tmp"
1064+ [[agents]]
1065+ name = "a"
1066+ layer = "Safety"
1067+ cli_tool = "echo"
1068+ task = "t"
1069+ [agents.pre_check]
1070+ kind = "always"
1071+ "# ;
1072+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1073+ assert_eq ! ( config. agents[ 0 ] . pre_check, Some ( PreCheckStrategy :: Always ) ) ;
1074+ }
1075+
1076+ #[ test]
1077+ fn test_config_parse_pre_check_git_diff ( ) {
1078+ let toml_str = r#"
1079+ working_dir = "/tmp"
1080+ [nightwatch]
1081+ [compound_review]
1082+ schedule = "0 0 * * *"
1083+ repo_path = "/tmp"
1084+ [[agents]]
1085+ name = "a"
1086+ layer = "Safety"
1087+ cli_tool = "echo"
1088+ task = "t"
1089+ [agents.pre_check]
1090+ kind = "git-diff"
1091+ watch_paths = ["crates/", "Cargo.toml"]
1092+ "# ;
1093+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1094+ match & config. agents [ 0 ] . pre_check {
1095+ Some ( PreCheckStrategy :: GitDiff { watch_paths } ) => {
1096+ assert_eq ! (
1097+ watch_paths,
1098+ & vec![ "crates/" . to_string( ) , "Cargo.toml" . to_string( ) ]
1099+ ) ;
1100+ }
1101+ other => panic ! ( "expected GitDiff, got {:?}" , other) ,
1102+ }
1103+ }
1104+
1105+ #[ test]
1106+ fn test_config_parse_pre_check_gitea_issue ( ) {
1107+ let toml_str = r#"
1108+ working_dir = "/tmp"
1109+ [nightwatch]
1110+ [compound_review]
1111+ schedule = "0 0 * * *"
1112+ repo_path = "/tmp"
1113+ [[agents]]
1114+ name = "a"
1115+ layer = "Safety"
1116+ cli_tool = "echo"
1117+ task = "t"
1118+ [agents.pre_check]
1119+ kind = "gitea-issue"
1120+ issue_number = 637
1121+ "# ;
1122+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1123+ match & config. agents [ 0 ] . pre_check {
1124+ Some ( PreCheckStrategy :: GiteaIssue { issue_number } ) => {
1125+ assert_eq ! ( * issue_number, 637 ) ;
1126+ }
1127+ other => panic ! ( "expected GiteaIssue, got {:?}" , other) ,
1128+ }
1129+ }
1130+
1131+ #[ test]
1132+ fn test_config_parse_pre_check_shell ( ) {
1133+ let toml_str = r#"
1134+ working_dir = "/tmp"
1135+ [nightwatch]
1136+ [compound_review]
1137+ schedule = "0 0 * * *"
1138+ repo_path = "/tmp"
1139+ [[agents]]
1140+ name = "a"
1141+ layer = "Safety"
1142+ cli_tool = "echo"
1143+ task = "t"
1144+ [agents.pre_check]
1145+ kind = "shell"
1146+ script = "echo hello"
1147+ "# ;
1148+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1149+ match & config. agents [ 0 ] . pre_check {
1150+ Some ( PreCheckStrategy :: Shell {
1151+ script,
1152+ timeout_secs,
1153+ } ) => {
1154+ assert_eq ! ( script, "echo hello" ) ;
1155+ assert_eq ! ( * timeout_secs, 60 ) ; // default
1156+ }
1157+ other => panic ! ( "expected Shell, got {:?}" , other) ,
1158+ }
1159+ }
1160+
1161+ #[ test]
1162+ fn test_config_parse_pre_check_shell_custom_timeout ( ) {
1163+ let toml_str = r#"
1164+ working_dir = "/tmp"
1165+ [nightwatch]
1166+ [compound_review]
1167+ schedule = "0 0 * * *"
1168+ repo_path = "/tmp"
1169+ [[agents]]
1170+ name = "a"
1171+ layer = "Safety"
1172+ cli_tool = "echo"
1173+ task = "t"
1174+ [agents.pre_check]
1175+ kind = "shell"
1176+ script = "test -f /tmp/flag"
1177+ timeout_secs = 10
1178+ "# ;
1179+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1180+ match & config. agents [ 0 ] . pre_check {
1181+ Some ( PreCheckStrategy :: Shell {
1182+ script,
1183+ timeout_secs,
1184+ } ) => {
1185+ assert_eq ! ( script, "test -f /tmp/flag" ) ;
1186+ assert_eq ! ( * timeout_secs, 10 ) ;
1187+ }
1188+ other => panic ! ( "expected Shell, got {:?}" , other) ,
1189+ }
1190+ }
1191+
1192+ #[ test]
1193+ fn test_config_parse_no_pre_check ( ) {
1194+ let toml_str = r#"
1195+ working_dir = "/tmp"
1196+ [nightwatch]
1197+ [compound_review]
1198+ schedule = "0 0 * * *"
1199+ repo_path = "/tmp"
1200+ [[agents]]
1201+ name = "a"
1202+ layer = "Safety"
1203+ cli_tool = "echo"
1204+ task = "t"
1205+ "# ;
1206+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1207+ assert ! ( config. agents[ 0 ] . pre_check. is_none( ) ) ;
1208+ }
1209+
1210+ #[ test]
1211+ fn test_config_validate_gitea_issue_requires_workflow ( ) {
1212+ let toml_str = r#"
1213+ working_dir = "/tmp"
1214+ [nightwatch]
1215+ [compound_review]
1216+ schedule = "0 0 * * *"
1217+ repo_path = "/tmp"
1218+ [[agents]]
1219+ name = "a"
1220+ layer = "Safety"
1221+ cli_tool = "echo"
1222+ task = "t"
1223+ [agents.pre_check]
1224+ kind = "gitea-issue"
1225+ issue_number = 42
1226+ "# ;
1227+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1228+ let result = config. validate ( ) ;
1229+ assert ! ( result. is_err( ) ) ;
1230+ let err = result. unwrap_err ( ) . to_string ( ) ;
1231+ assert ! (
1232+ err. contains( "gitea-issue" ) ,
1233+ "error should mention gitea-issue: {}" ,
1234+ err
1235+ ) ;
1236+ }
1237+
1238+ #[ test]
1239+ fn test_config_validate_gitea_issue_with_workflow_ok ( ) {
1240+ let toml_str = r#"
1241+ working_dir = "/tmp"
1242+ [nightwatch]
1243+ [compound_review]
1244+ schedule = "0 0 * * *"
1245+ repo_path = "/tmp"
1246+ [workflow]
1247+ enabled = true
1248+ workflow_file = "./WORKFLOW.md"
1249+ [workflow.tracker]
1250+ kind = "gitea"
1251+ endpoint = "https://git.example.com"
1252+ api_key = "token123"
1253+ owner = "owner"
1254+ repo = "repo"
1255+ [[agents]]
1256+ name = "a"
1257+ layer = "Safety"
1258+ cli_tool = "echo"
1259+ task = "t"
1260+ [agents.pre_check]
1261+ kind = "gitea-issue"
1262+ issue_number = 42
1263+ "# ;
1264+ let config = OrchestratorConfig :: from_toml ( toml_str) . unwrap ( ) ;
1265+ assert ! ( config. validate( ) . is_ok( ) ) ;
1266+ }
10111267}
0 commit comments