Skip to content

Commit ea6a8cf

Browse files
committed
feat(orchestrator): add typed PreCheckStrategy enum with shell and git-diff strategies Refs #155
1 parent 581792b commit ea6a8cf

8 files changed

Lines changed: 535 additions & 2 deletions

File tree

crates/terraphim_orchestrator/src/config.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@ use std::path::PathBuf;
22

33
use 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)]
734
pub 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
}

crates/terraphim_orchestrator/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ pub enum OrchestratorError {
3939

4040
#[error(transparent)]
4141
Io(#[from] std::io::Error),
42+
43+
#[error("pre-check configuration error for agent '{agent}': {reason}")]
44+
PreCheckConfig { agent: String, reason: String },
4245
}

0 commit comments

Comments
 (0)