Skip to main content

sidebyside_core/
plan.rs

1//! Plan data structures
2//!
3//! This module defines the core plan representation used throughout the SDK.
4
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::ids::{DecisionId, PlanId, StepId};
9
10/// A plan representing a sequence of steps to execute
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Plan {
13    /// Unique identifier for this plan
14    pub id: PlanId,
15    /// Human-readable name for the plan
16    pub name: String,
17    /// Optional description of what this plan does
18    pub description: Option<String>,
19    /// Steps to execute in this plan
20    pub steps: Vec<PlanStep>,
21    /// Decision points where Claude can make runtime decisions
22    pub decision_points: Vec<DecisionPoint>,
23    /// Additional metadata
24    pub metadata: HashMap<String, serde_json::Value>,
25}
26
27impl Plan {
28    /// Create a new plan with the given ID and name
29    #[must_use]
30    pub fn new(id: PlanId, name: impl Into<String>) -> Self {
31        Self {
32            id,
33            name: name.into(),
34            description: None,
35            steps: Vec::new(),
36            decision_points: Vec::new(),
37            metadata: HashMap::new(),
38        }
39    }
40
41    /// Get a step by its ID
42    #[must_use]
43    pub fn get_step(&self, step_id: &StepId) -> Option<&PlanStep> {
44        self.steps.iter().find(|s| &s.id == step_id)
45    }
46
47    /// Get a decision point by its ID
48    #[must_use]
49    pub fn get_decision_point(&self, decision_id: &DecisionId) -> Option<&DecisionPoint> {
50        self.decision_points.iter().find(|d| &d.id == decision_id)
51    }
52
53    /// Get all steps whose dependencies are satisfied
54    ///
55    /// Returns steps that:
56    /// - Have all dependencies in the `completed` set
57    /// - Are not themselves in the `completed` set
58    #[must_use]
59    pub fn get_ready_steps(&self, completed: &HashSet<StepId>) -> Vec<&PlanStep> {
60        self.steps
61            .iter()
62            .filter(|step| {
63                // Not already completed
64                !completed.contains(&step.id)
65                    // All dependencies are satisfied
66                    && step.dependencies.iter().all(|dep| completed.contains(dep))
67            })
68            .collect()
69    }
70
71    /// Return steps in valid topological execution order
72    ///
73    /// Returns `None` if the plan has a dependency cycle.
74    #[must_use]
75    pub fn topological_order(&self) -> Option<Vec<StepId>> {
76        let mut in_degree: HashMap<&StepId, usize> = HashMap::new();
77        let mut dependents: HashMap<&StepId, Vec<&StepId>> = HashMap::new();
78
79        // Initialize in-degrees and build reverse adjacency list
80        for step in &self.steps {
81            in_degree.entry(&step.id).or_insert(0);
82            for dep in &step.dependencies {
83                *in_degree.entry(&step.id).or_insert(0) += 1;
84                dependents.entry(dep).or_default().push(&step.id);
85            }
86        }
87
88        // Find all steps with no dependencies
89        let mut queue: VecDeque<&StepId> = in_degree
90            .iter()
91            .filter(|(_, &degree)| degree == 0)
92            .map(|(&id, _)| id)
93            .collect();
94
95        let mut result = Vec::new();
96
97        while let Some(step_id) = queue.pop_front() {
98            result.push(step_id.clone());
99
100            if let Some(deps) = dependents.get(step_id) {
101                for dep in deps {
102                    if let Some(degree) = in_degree.get_mut(dep) {
103                        *degree -= 1;
104                        if *degree == 0 {
105                            queue.push_back(dep);
106                        }
107                    }
108                }
109            }
110        }
111
112        // If we didn't process all steps, there's a cycle
113        if result.len() == self.steps.len() {
114            Some(result)
115        } else {
116            None
117        }
118    }
119}
120
121/// A single step within a plan
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct PlanStep {
124    /// Unique identifier for this step
125    pub id: StepId,
126    /// Human-readable name for the step
127    pub name: String,
128    /// The activity to execute for this step
129    pub activity: ActivitySpec,
130    /// Input data for the activity
131    pub inputs: HashMap<String, serde_json::Value>,
132    /// Expected output keys
133    pub outputs: Vec<String>,
134    /// IDs of steps this step depends on
135    pub dependencies: Vec<StepId>,
136}
137
138impl PlanStep {
139    /// Create a new plan step
140    #[must_use]
141    pub fn new(id: StepId, name: impl Into<String>, activity: ActivitySpec) -> Self {
142        Self {
143            id,
144            name: name.into(),
145            activity,
146            inputs: HashMap::new(),
147            outputs: Vec::new(),
148            dependencies: Vec::new(),
149        }
150    }
151}
152
153/// Specification for an activity to execute
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "type")]
156pub enum ActivitySpec {
157    /// An atomic activity that executes as a single unit
158    Atomic {
159        /// The activity type name
160        activity_type: String,
161        /// Retry configuration
162        retry_policy: Option<RetryPolicy>,
163    },
164    /// A composite activity made up of sub-activities
165    Composite {
166        /// Sub-activities to execute
167        sub_activities: Vec<ActivitySpec>,
168    },
169    /// A decision point where Claude makes a runtime decision
170    ClaudeDecision {
171        /// Context template for the decision
172        context_template: String,
173        /// Allowed actions for this decision
174        allowed_actions: Vec<String>,
175    },
176}
177
178/// Retry policy for activities
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct RetryPolicy {
181    /// Maximum number of retry attempts
182    pub max_attempts: u32,
183    /// Initial backoff interval in milliseconds
184    pub initial_interval_ms: u64,
185    /// Maximum backoff interval in milliseconds
186    pub max_interval_ms: u64,
187    /// Backoff multiplier
188    pub backoff_coefficient: f64,
189}
190
191impl Default for RetryPolicy {
192    fn default() -> Self {
193        Self {
194            max_attempts: 3,
195            initial_interval_ms: 1000,
196            max_interval_ms: 60000,
197            backoff_coefficient: 2.0,
198        }
199    }
200}
201
202/// A decision point in the plan where Claude can make runtime decisions
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct DecisionPoint {
205    /// Unique identifier for this decision point
206    pub id: DecisionId,
207    /// Human-readable name
208    pub name: String,
209    /// Step to execute after (if any)
210    pub after_step: Option<StepId>,
211    /// Context template for Claude's decision
212    pub context_template: String,
213    /// Constraints on the decision
214    pub constraints: Vec<String>,
215}
216
217impl DecisionPoint {
218    /// Create a new decision point
219    #[must_use]
220    pub fn new(id: DecisionId, name: impl Into<String>) -> Self {
221        Self {
222            id,
223            name: name.into(),
224            after_step: None,
225            context_template: String::new(),
226            constraints: Vec::new(),
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::ids::StepId;
235
236    #[test]
237    fn test_plan_creation() {
238        let plan = Plan::new(PlanId::generate(), "test-plan");
239        assert_eq!(plan.name, "test-plan");
240        assert!(plan.steps.is_empty());
241    }
242
243    #[test]
244    fn test_plan_step_creation() {
245        let step = PlanStep::new(
246            StepId::generate(),
247            "test-step",
248            ActivitySpec::Atomic {
249                activity_type: "test-activity".to_string(),
250                retry_policy: None,
251            },
252        );
253        assert_eq!(step.name, "test-step");
254    }
255
256    #[test]
257    fn test_retry_policy_default() {
258        let policy = RetryPolicy::default();
259        assert_eq!(policy.max_attempts, 3);
260        assert_eq!(policy.backoff_coefficient, 2.0);
261    }
262
263    #[test]
264    fn test_get_ready_steps_no_dependencies() {
265        let mut plan = Plan::new(PlanId::generate(), "test-plan");
266        let step1 = PlanStep::new(
267            StepId::new("step1").unwrap(),
268            "Step 1",
269            ActivitySpec::Atomic {
270                activity_type: "activity1".to_string(),
271                retry_policy: None,
272            },
273        );
274        let step2 = PlanStep::new(
275            StepId::new("step2").unwrap(),
276            "Step 2",
277            ActivitySpec::Atomic {
278                activity_type: "activity2".to_string(),
279                retry_policy: None,
280            },
281        );
282        plan.steps.push(step1);
283        plan.steps.push(step2);
284
285        let completed = HashSet::new();
286        let ready = plan.get_ready_steps(&completed);
287        assert_eq!(ready.len(), 2);
288    }
289
290    #[test]
291    fn test_get_ready_steps_with_dependencies() {
292        let mut plan = Plan::new(PlanId::generate(), "test-plan");
293        let step1 = PlanStep::new(
294            StepId::new("step1").unwrap(),
295            "Step 1",
296            ActivitySpec::Atomic {
297                activity_type: "activity1".to_string(),
298                retry_policy: None,
299            },
300        );
301        let mut step2 = PlanStep::new(
302            StepId::new("step2").unwrap(),
303            "Step 2",
304            ActivitySpec::Atomic {
305                activity_type: "activity2".to_string(),
306                retry_policy: None,
307            },
308        );
309        step2.dependencies.push(StepId::new("step1").unwrap());
310        plan.steps.push(step1);
311        plan.steps.push(step2);
312
313        // Initially only step1 is ready
314        let completed = HashSet::new();
315        let ready = plan.get_ready_steps(&completed);
316        assert_eq!(ready.len(), 1);
317        assert_eq!(ready[0].id.as_str(), "step1");
318
319        // After completing step1, step2 is ready
320        let mut completed = HashSet::new();
321        completed.insert(StepId::new("step1").unwrap());
322        let ready = plan.get_ready_steps(&completed);
323        assert_eq!(ready.len(), 1);
324        assert_eq!(ready[0].id.as_str(), "step2");
325    }
326
327    #[test]
328    fn test_topological_order_simple() {
329        let mut plan = Plan::new(PlanId::generate(), "test-plan");
330        let step1 = PlanStep::new(
331            StepId::new("step1").unwrap(),
332            "Step 1",
333            ActivitySpec::Atomic {
334                activity_type: "activity1".to_string(),
335                retry_policy: None,
336            },
337        );
338        let mut step2 = PlanStep::new(
339            StepId::new("step2").unwrap(),
340            "Step 2",
341            ActivitySpec::Atomic {
342                activity_type: "activity2".to_string(),
343                retry_policy: None,
344            },
345        );
346        step2.dependencies.push(StepId::new("step1").unwrap());
347        let mut step3 = PlanStep::new(
348            StepId::new("step3").unwrap(),
349            "Step 3",
350            ActivitySpec::Atomic {
351                activity_type: "activity3".to_string(),
352                retry_policy: None,
353            },
354        );
355        step3.dependencies.push(StepId::new("step2").unwrap());
356
357        plan.steps.push(step1);
358        plan.steps.push(step2);
359        plan.steps.push(step3);
360
361        let order = plan.topological_order().unwrap();
362        assert_eq!(order.len(), 3);
363
364        // Verify ordering constraints
365        let step1_pos = order.iter().position(|id| id.as_str() == "step1").unwrap();
366        let step2_pos = order.iter().position(|id| id.as_str() == "step2").unwrap();
367        let step3_pos = order.iter().position(|id| id.as_str() == "step3").unwrap();
368
369        assert!(step1_pos < step2_pos);
370        assert!(step2_pos < step3_pos);
371    }
372
373    #[test]
374    fn test_topological_order_with_cycle() {
375        let mut plan = Plan::new(PlanId::generate(), "test-plan");
376        let mut step1 = PlanStep::new(
377            StepId::new("step1").unwrap(),
378            "Step 1",
379            ActivitySpec::Atomic {
380                activity_type: "activity1".to_string(),
381                retry_policy: None,
382            },
383        );
384        step1.dependencies.push(StepId::new("step2").unwrap());
385
386        let mut step2 = PlanStep::new(
387            StepId::new("step2").unwrap(),
388            "Step 2",
389            ActivitySpec::Atomic {
390                activity_type: "activity2".to_string(),
391                retry_policy: None,
392            },
393        );
394        step2.dependencies.push(StepId::new("step1").unwrap());
395
396        plan.steps.push(step1);
397        plan.steps.push(step2);
398
399        // Should return None due to cycle
400        assert!(plan.topological_order().is_none());
401    }
402}