From d74ecf74418011e4fc770ad7c8d5bc9a3fccdb61 Mon Sep 17 00:00:00 2001 From: Jobdori Date: Sat, 4 Apr 2026 00:40:50 +0900 Subject: [PATCH] feat(runtime): policy engine for autonomous lane management --- rust/crates/runtime/src/lib.rs | 5 + rust/crates/runtime/src/policy_engine.rs | 458 +++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 rust/crates/runtime/src/policy_engine.rs diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 1c01a3f..5490c3c 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -14,6 +14,7 @@ mod mcp_stdio; pub mod mcp_tool_bridge; mod oauth; pub mod permission_enforcer; +mod policy_engine; mod permissions; mod prompt; mod remote; @@ -76,6 +77,10 @@ pub use oauth::{ OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet, PkceChallengeMethod, PkceCodePair, }; +pub use policy_engine::{ + evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, + PolicyEngine, PolicyRule, ReviewStatus, +}; pub use permissions::{ PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy, PermissionPromptDecision, PermissionPrompter, PermissionRequest, diff --git a/rust/crates/runtime/src/policy_engine.rs b/rust/crates/runtime/src/policy_engine.rs new file mode 100644 index 0000000..eebd7b9 --- /dev/null +++ b/rust/crates/runtime/src/policy_engine.rs @@ -0,0 +1,458 @@ +use std::time::Duration; + +pub type GreenLevel = u8; + +const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyRule { + pub name: String, + pub condition: PolicyCondition, + pub action: PolicyAction, + pub priority: u32, +} + +impl PolicyRule { + #[must_use] + pub fn new( + name: impl Into, + condition: PolicyCondition, + action: PolicyAction, + priority: u32, + ) -> Self { + Self { + name: name.into(), + condition, + action, + priority, + } + } + + #[must_use] + pub fn matches(&self, context: &LaneContext) -> bool { + self.condition.matches(context) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyCondition { + And(Vec), + Or(Vec), + GreenAt { level: GreenLevel }, + StaleBranch, + StartupBlocked, + LaneCompleted, + ReviewPassed, + ScopedDiff, + TimedOut { duration: Duration }, +} + +impl PolicyCondition { + #[must_use] + pub fn matches(&self, context: &LaneContext) -> bool { + match self { + Self::And(conditions) => conditions + .iter() + .all(|condition| condition.matches(context)), + Self::Or(conditions) => conditions + .iter() + .any(|condition| condition.matches(context)), + Self::GreenAt { level } => context.green_level >= *level, + Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD, + Self::StartupBlocked => context.blocker == LaneBlocker::Startup, + Self::LaneCompleted => context.completed, + Self::ReviewPassed => context.review_status == ReviewStatus::Approved, + Self::ScopedDiff => context.diff_scope == DiffScope::Scoped, + Self::TimedOut { duration } => context.branch_freshness >= *duration, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyAction { + MergeToDev, + MergeForward, + RecoverOnce, + Escalate { reason: String }, + CloseoutLane, + CleanupSession, + Notify { channel: String }, + Block { reason: String }, + Chain(Vec), +} + +impl PolicyAction { + fn flatten_into(&self, actions: &mut Vec) { + match self { + Self::Chain(chained) => { + for action in chained { + action.flatten_into(actions); + } + } + _ => actions.push(self.clone()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaneBlocker { + None, + Startup, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewStatus { + Pending, + Approved, + Rejected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffScope { + Full, + Scoped, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaneContext { + pub lane_id: String, + pub green_level: GreenLevel, + pub branch_freshness: Duration, + pub blocker: LaneBlocker, + pub review_status: ReviewStatus, + pub diff_scope: DiffScope, + pub completed: bool, +} + +impl LaneContext { + #[must_use] + pub fn new( + lane_id: impl Into, + green_level: GreenLevel, + branch_freshness: Duration, + blocker: LaneBlocker, + review_status: ReviewStatus, + diff_scope: DiffScope, + completed: bool, + ) -> Self { + Self { + lane_id: lane_id.into(), + green_level, + branch_freshness, + blocker, + review_status, + diff_scope, + completed, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyEngine { + rules: Vec, +} + +impl PolicyEngine { + #[must_use] + pub fn new(mut rules: Vec) -> Self { + rules.sort_by_key(|rule| rule.priority); + Self { rules } + } + + #[must_use] + pub fn rules(&self) -> &[PolicyRule] { + &self.rules + } + + #[must_use] + pub fn evaluate(&self, context: &LaneContext) -> Vec { + evaluate(self, context) + } +} + +#[must_use] +pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec { + let mut actions = Vec::new(); + for rule in &engine.rules { + if rule.matches(context) { + rule.action.flatten_into(&mut actions); + } + } + actions +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{ + evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine, + PolicyRule, ReviewStatus, STALE_BRANCH_THRESHOLD, + }; + + fn default_context() -> LaneContext { + LaneContext::new( + "lane-7", + 0, + Duration::from_secs(0), + LaneBlocker::None, + ReviewStatus::Pending, + DiffScope::Full, + false, + ) + } + + #[test] + fn merge_to_dev_rule_fires_for_green_scoped_reviewed_lane() { + // given + let engine = PolicyEngine::new(vec![PolicyRule::new( + "merge-to-dev", + PolicyCondition::And(vec![ + PolicyCondition::GreenAt { level: 2 }, + PolicyCondition::ScopedDiff, + PolicyCondition::ReviewPassed, + ]), + PolicyAction::MergeToDev, + 20, + )]); + let context = LaneContext::new( + "lane-7", + 3, + Duration::from_secs(5), + LaneBlocker::None, + ReviewStatus::Approved, + DiffScope::Scoped, + false, + ); + + // when + let actions = engine.evaluate(&context); + + // then + assert_eq!(actions, vec![PolicyAction::MergeToDev]); + } + + #[test] + fn stale_branch_rule_fires_at_threshold() { + // given + let engine = PolicyEngine::new(vec![PolicyRule::new( + "merge-forward", + PolicyCondition::StaleBranch, + PolicyAction::MergeForward, + 10, + )]); + let context = LaneContext::new( + "lane-7", + 1, + STALE_BRANCH_THRESHOLD, + LaneBlocker::None, + ReviewStatus::Pending, + DiffScope::Full, + false, + ); + + // when + let actions = engine.evaluate(&context); + + // then + assert_eq!(actions, vec![PolicyAction::MergeForward]); + } + + #[test] + fn startup_blocked_rule_recovers_then_escalates() { + // given + let engine = PolicyEngine::new(vec![PolicyRule::new( + "startup-recovery", + PolicyCondition::StartupBlocked, + PolicyAction::Chain(vec![ + PolicyAction::RecoverOnce, + PolicyAction::Escalate { + reason: "startup remained blocked".to_string(), + }, + ]), + 15, + )]); + let context = LaneContext::new( + "lane-7", + 0, + Duration::from_secs(0), + LaneBlocker::Startup, + ReviewStatus::Pending, + DiffScope::Full, + false, + ); + + // when + let actions = engine.evaluate(&context); + + // then + assert_eq!( + actions, + vec![ + PolicyAction::RecoverOnce, + PolicyAction::Escalate { + reason: "startup remained blocked".to_string(), + }, + ] + ); + } + + #[test] + fn completed_lane_rule_closes_out_and_cleans_up() { + // given + let engine = PolicyEngine::new(vec![PolicyRule::new( + "lane-closeout", + PolicyCondition::LaneCompleted, + PolicyAction::Chain(vec![ + PolicyAction::CloseoutLane, + PolicyAction::CleanupSession, + ]), + 30, + )]); + let context = LaneContext::new( + "lane-7", + 0, + Duration::from_secs(0), + LaneBlocker::None, + ReviewStatus::Pending, + DiffScope::Full, + true, + ); + + // when + let actions = engine.evaluate(&context); + + // then + assert_eq!( + actions, + vec![PolicyAction::CloseoutLane, PolicyAction::CleanupSession] + ); + } + + #[test] + fn matching_rules_are_returned_in_priority_order_with_stable_ties() { + // given + let engine = PolicyEngine::new(vec![ + PolicyRule::new( + "late-cleanup", + PolicyCondition::And(vec![]), + PolicyAction::CleanupSession, + 30, + ), + PolicyRule::new( + "first-notify", + PolicyCondition::And(vec![]), + PolicyAction::Notify { + channel: "ops".to_string(), + }, + 10, + ), + PolicyRule::new( + "second-notify", + PolicyCondition::And(vec![]), + PolicyAction::Notify { + channel: "review".to_string(), + }, + 10, + ), + PolicyRule::new( + "merge", + PolicyCondition::And(vec![]), + PolicyAction::MergeToDev, + 20, + ), + ]); + let context = default_context(); + + // when + let actions = evaluate(&engine, &context); + + // then + assert_eq!( + actions, + vec![ + PolicyAction::Notify { + channel: "ops".to_string(), + }, + PolicyAction::Notify { + channel: "review".to_string(), + }, + PolicyAction::MergeToDev, + PolicyAction::CleanupSession, + ] + ); + } + + #[test] + fn combinators_handle_empty_cases_and_nested_chains() { + // given + let engine = PolicyEngine::new(vec![ + PolicyRule::new( + "empty-and", + PolicyCondition::And(vec![]), + PolicyAction::Notify { + channel: "orchestrator".to_string(), + }, + 5, + ), + PolicyRule::new( + "empty-or", + PolicyCondition::Or(vec![]), + PolicyAction::Block { + reason: "should not fire".to_string(), + }, + 10, + ), + PolicyRule::new( + "nested", + PolicyCondition::Or(vec![ + PolicyCondition::StartupBlocked, + PolicyCondition::And(vec![ + PolicyCondition::GreenAt { level: 2 }, + PolicyCondition::TimedOut { + duration: Duration::from_secs(5), + }, + ]), + ]), + PolicyAction::Chain(vec![ + PolicyAction::Notify { + channel: "alerts".to_string(), + }, + PolicyAction::Chain(vec![ + PolicyAction::MergeForward, + PolicyAction::CleanupSession, + ]), + ]), + 15, + ), + ]); + let context = LaneContext::new( + "lane-7", + 2, + Duration::from_secs(10), + LaneBlocker::External, + ReviewStatus::Pending, + DiffScope::Full, + false, + ); + + // when + let actions = engine.evaluate(&context); + + // then + assert_eq!( + actions, + vec![ + PolicyAction::Notify { + channel: "orchestrator".to_string(), + }, + PolicyAction::Notify { + channel: "alerts".to_string(), + }, + PolicyAction::MergeForward, + PolicyAction::CleanupSession, + ] + ); + } +}