Просмотр исходного кода

feat(runtime): policy engine for autonomous lane management

Jobdori 2 месяцев назад
Родитель
Сommit
d74ecf7441
2 измененных файлов с 463 добавлено и 0 удалено
  1. 5 0
      rust/crates/runtime/src/lib.rs
  2. 458 0
      rust/crates/runtime/src/policy_engine.rs

+ 5 - 0
rust/crates/runtime/src/lib.rs

@@ -14,6 +14,7 @@ mod mcp_stdio;
 pub mod mcp_tool_bridge;
 pub mod mcp_tool_bridge;
 mod oauth;
 mod oauth;
 pub mod permission_enforcer;
 pub mod permission_enforcer;
+mod policy_engine;
 mod permissions;
 mod permissions;
 mod prompt;
 mod prompt;
 mod remote;
 mod remote;
@@ -76,6 +77,10 @@ pub use oauth::{
     OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
     OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
     PkceChallengeMethod, PkceCodePair,
     PkceChallengeMethod, PkceCodePair,
 };
 };
+pub use policy_engine::{
+    evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
+    PolicyEngine, PolicyRule, ReviewStatus,
+};
 pub use permissions::{
 pub use permissions::{
     PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
     PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
     PermissionPromptDecision, PermissionPrompter, PermissionRequest,
     PermissionPromptDecision, PermissionPrompter, PermissionRequest,

+ 458 - 0
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<String>,
+        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<PolicyCondition>),
+    Or(Vec<PolicyCondition>),
+    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<PolicyAction>),
+}
+
+impl PolicyAction {
+    fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
+        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<String>,
+        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<PolicyRule>,
+}
+
+impl PolicyEngine {
+    #[must_use]
+    pub fn new(mut rules: Vec<PolicyRule>) -> 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<PolicyAction> {
+        evaluate(self, context)
+    }
+}
+
+#[must_use]
+pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
+    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,
+            ]
+        );
+    }
+}