sidebyside-core 0.1.0

Core domain types for Sidebyside SDK
Documentation
//! Plan data structures
//!
//! This module defines the core plan representation used throughout the SDK.

use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};

use crate::ids::{DecisionId, PlanId, StepId};

/// A plan representing a sequence of steps to execute
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
    /// Unique identifier for this plan
    pub id: PlanId,
    /// Human-readable name for the plan
    pub name: String,
    /// Optional description of what this plan does
    pub description: Option<String>,
    /// Steps to execute in this plan
    pub steps: Vec<PlanStep>,
    /// Decision points where Claude can make runtime decisions
    pub decision_points: Vec<DecisionPoint>,
    /// Additional metadata
    pub metadata: HashMap<String, serde_json::Value>,
}

impl Plan {
    /// Create a new plan with the given ID and name
    #[must_use]
    pub fn new(id: PlanId, name: impl Into<String>) -> Self {
        Self {
            id,
            name: name.into(),
            description: None,
            steps: Vec::new(),
            decision_points: Vec::new(),
            metadata: HashMap::new(),
        }
    }

    /// Get a step by its ID
    #[must_use]
    pub fn get_step(&self, step_id: &StepId) -> Option<&PlanStep> {
        self.steps.iter().find(|s| &s.id == step_id)
    }

    /// Get a decision point by its ID
    #[must_use]
    pub fn get_decision_point(&self, decision_id: &DecisionId) -> Option<&DecisionPoint> {
        self.decision_points.iter().find(|d| &d.id == decision_id)
    }

    /// Get all steps whose dependencies are satisfied
    ///
    /// Returns steps that:
    /// - Have all dependencies in the `completed` set
    /// - Are not themselves in the `completed` set
    #[must_use]
    pub fn get_ready_steps(&self, completed: &HashSet<StepId>) -> Vec<&PlanStep> {
        self.steps
            .iter()
            .filter(|step| {
                // Not already completed
                !completed.contains(&step.id)
                    // All dependencies are satisfied
                    && step.dependencies.iter().all(|dep| completed.contains(dep))
            })
            .collect()
    }

    /// Return steps in valid topological execution order
    ///
    /// Returns `None` if the plan has a dependency cycle.
    #[must_use]
    pub fn topological_order(&self) -> Option<Vec<StepId>> {
        let mut in_degree: HashMap<&StepId, usize> = HashMap::new();
        let mut dependents: HashMap<&StepId, Vec<&StepId>> = HashMap::new();

        // Initialize in-degrees and build reverse adjacency list
        for step in &self.steps {
            in_degree.entry(&step.id).or_insert(0);
            for dep in &step.dependencies {
                *in_degree.entry(&step.id).or_insert(0) += 1;
                dependents.entry(dep).or_default().push(&step.id);
            }
        }

        // Find all steps with no dependencies
        let mut queue: VecDeque<&StepId> = in_degree
            .iter()
            .filter(|(_, &degree)| degree == 0)
            .map(|(&id, _)| id)
            .collect();

        let mut result = Vec::new();

        while let Some(step_id) = queue.pop_front() {
            result.push(step_id.clone());

            if let Some(deps) = dependents.get(step_id) {
                for dep in deps {
                    if let Some(degree) = in_degree.get_mut(dep) {
                        *degree -= 1;
                        if *degree == 0 {
                            queue.push_back(dep);
                        }
                    }
                }
            }
        }

        // If we didn't process all steps, there's a cycle
        if result.len() == self.steps.len() {
            Some(result)
        } else {
            None
        }
    }
}

/// A single step within a plan
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStep {
    /// Unique identifier for this step
    pub id: StepId,
    /// Human-readable name for the step
    pub name: String,
    /// The activity to execute for this step
    pub activity: ActivitySpec,
    /// Input data for the activity
    pub inputs: HashMap<String, serde_json::Value>,
    /// Expected output keys
    pub outputs: Vec<String>,
    /// IDs of steps this step depends on
    pub dependencies: Vec<StepId>,
}

impl PlanStep {
    /// Create a new plan step
    #[must_use]
    pub fn new(id: StepId, name: impl Into<String>, activity: ActivitySpec) -> Self {
        Self {
            id,
            name: name.into(),
            activity,
            inputs: HashMap::new(),
            outputs: Vec::new(),
            dependencies: Vec::new(),
        }
    }
}

/// Specification for an activity to execute
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ActivitySpec {
    /// An atomic activity that executes as a single unit
    Atomic {
        /// The activity type name
        activity_type: String,
        /// Retry configuration
        retry_policy: Option<RetryPolicy>,
    },
    /// A composite activity made up of sub-activities
    Composite {
        /// Sub-activities to execute
        sub_activities: Vec<ActivitySpec>,
    },
    /// A decision point where Claude makes a runtime decision
    ClaudeDecision {
        /// Context template for the decision
        context_template: String,
        /// Allowed actions for this decision
        allowed_actions: Vec<String>,
    },
}

/// Retry policy for activities
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryPolicy {
    /// Maximum number of retry attempts
    pub max_attempts: u32,
    /// Initial backoff interval in milliseconds
    pub initial_interval_ms: u64,
    /// Maximum backoff interval in milliseconds
    pub max_interval_ms: u64,
    /// Backoff multiplier
    pub backoff_coefficient: f64,
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            max_attempts: 3,
            initial_interval_ms: 1000,
            max_interval_ms: 60000,
            backoff_coefficient: 2.0,
        }
    }
}

/// A decision point in the plan where Claude can make runtime decisions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionPoint {
    /// Unique identifier for this decision point
    pub id: DecisionId,
    /// Human-readable name
    pub name: String,
    /// Step to execute after (if any)
    pub after_step: Option<StepId>,
    /// Context template for Claude's decision
    pub context_template: String,
    /// Constraints on the decision
    pub constraints: Vec<String>,
}

impl DecisionPoint {
    /// Create a new decision point
    #[must_use]
    pub fn new(id: DecisionId, name: impl Into<String>) -> Self {
        Self {
            id,
            name: name.into(),
            after_step: None,
            context_template: String::new(),
            constraints: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ids::StepId;

    #[test]
    fn test_plan_creation() {
        let plan = Plan::new(PlanId::generate(), "test-plan");
        assert_eq!(plan.name, "test-plan");
        assert!(plan.steps.is_empty());
    }

    #[test]
    fn test_plan_step_creation() {
        let step = PlanStep::new(
            StepId::generate(),
            "test-step",
            ActivitySpec::Atomic {
                activity_type: "test-activity".to_string(),
                retry_policy: None,
            },
        );
        assert_eq!(step.name, "test-step");
    }

    #[test]
    fn test_retry_policy_default() {
        let policy = RetryPolicy::default();
        assert_eq!(policy.max_attempts, 3);
        assert_eq!(policy.backoff_coefficient, 2.0);
    }

    #[test]
    fn test_get_ready_steps_no_dependencies() {
        let mut plan = Plan::new(PlanId::generate(), "test-plan");
        let step1 = PlanStep::new(
            StepId::new("step1").unwrap(),
            "Step 1",
            ActivitySpec::Atomic {
                activity_type: "activity1".to_string(),
                retry_policy: None,
            },
        );
        let step2 = PlanStep::new(
            StepId::new("step2").unwrap(),
            "Step 2",
            ActivitySpec::Atomic {
                activity_type: "activity2".to_string(),
                retry_policy: None,
            },
        );
        plan.steps.push(step1);
        plan.steps.push(step2);

        let completed = HashSet::new();
        let ready = plan.get_ready_steps(&completed);
        assert_eq!(ready.len(), 2);
    }

    #[test]
    fn test_get_ready_steps_with_dependencies() {
        let mut plan = Plan::new(PlanId::generate(), "test-plan");
        let step1 = PlanStep::new(
            StepId::new("step1").unwrap(),
            "Step 1",
            ActivitySpec::Atomic {
                activity_type: "activity1".to_string(),
                retry_policy: None,
            },
        );
        let mut step2 = PlanStep::new(
            StepId::new("step2").unwrap(),
            "Step 2",
            ActivitySpec::Atomic {
                activity_type: "activity2".to_string(),
                retry_policy: None,
            },
        );
        step2.dependencies.push(StepId::new("step1").unwrap());
        plan.steps.push(step1);
        plan.steps.push(step2);

        // Initially only step1 is ready
        let completed = HashSet::new();
        let ready = plan.get_ready_steps(&completed);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id.as_str(), "step1");

        // After completing step1, step2 is ready
        let mut completed = HashSet::new();
        completed.insert(StepId::new("step1").unwrap());
        let ready = plan.get_ready_steps(&completed);
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id.as_str(), "step2");
    }

    #[test]
    fn test_topological_order_simple() {
        let mut plan = Plan::new(PlanId::generate(), "test-plan");
        let step1 = PlanStep::new(
            StepId::new("step1").unwrap(),
            "Step 1",
            ActivitySpec::Atomic {
                activity_type: "activity1".to_string(),
                retry_policy: None,
            },
        );
        let mut step2 = PlanStep::new(
            StepId::new("step2").unwrap(),
            "Step 2",
            ActivitySpec::Atomic {
                activity_type: "activity2".to_string(),
                retry_policy: None,
            },
        );
        step2.dependencies.push(StepId::new("step1").unwrap());
        let mut step3 = PlanStep::new(
            StepId::new("step3").unwrap(),
            "Step 3",
            ActivitySpec::Atomic {
                activity_type: "activity3".to_string(),
                retry_policy: None,
            },
        );
        step3.dependencies.push(StepId::new("step2").unwrap());

        plan.steps.push(step1);
        plan.steps.push(step2);
        plan.steps.push(step3);

        let order = plan.topological_order().unwrap();
        assert_eq!(order.len(), 3);

        // Verify ordering constraints
        let step1_pos = order.iter().position(|id| id.as_str() == "step1").unwrap();
        let step2_pos = order.iter().position(|id| id.as_str() == "step2").unwrap();
        let step3_pos = order.iter().position(|id| id.as_str() == "step3").unwrap();

        assert!(step1_pos < step2_pos);
        assert!(step2_pos < step3_pos);
    }

    #[test]
    fn test_topological_order_with_cycle() {
        let mut plan = Plan::new(PlanId::generate(), "test-plan");
        let mut step1 = PlanStep::new(
            StepId::new("step1").unwrap(),
            "Step 1",
            ActivitySpec::Atomic {
                activity_type: "activity1".to_string(),
                retry_policy: None,
            },
        );
        step1.dependencies.push(StepId::new("step2").unwrap());

        let mut step2 = PlanStep::new(
            StepId::new("step2").unwrap(),
            "Step 2",
            ActivitySpec::Atomic {
                activity_type: "activity2".to_string(),
                retry_policy: None,
            },
        );
        step2.dependencies.push(StepId::new("step1").unwrap());

        plan.steps.push(step1);
        plan.steps.push(step2);

        // Should return None due to cycle
        assert!(plan.topological_order().is_none());
    }
}