Skip to main content

git_internal/internal/object/
plan.rs

1//! AI Plan snapshot.
2//!
3//! `Plan` stores one immutable planning revision for an `Intent`. It
4//! records the chosen strategy, the stable step structure, and the
5//! frozen planning context used to derive that strategy.
6//!
7//! # How to use this object
8//!
9//! - Create a base `Plan` after analyzing an `Intent`.
10//! - Create a new `Plan` revision when replanning is needed.
11//! - Use multi-parent revisions to represent merged planning branches.
12//! - Freeze planning-time context through `context_frames`.
13//! - Keep analysis-time context on the owning `Intent`; do not reuse
14//!   this field for prompt-analysis inputs.
15//!
16//! # How it works with other objects
17//!
18//! - `Intent` is the canonical owner via `Plan.intent`.
19//! - `Task.origin_step_id` points back to the logical step that spawned
20//!   delegated work.
21//! - `PlanStepEvent` records runtime step status, produced context,
22//!   outputs, and spawned tasks.
23//! - `ContextFrame` stores incremental context facts referenced by the
24//!   plan or step events.
25//!
26//! # How Libra should call it
27//!
28//! Libra should write a new `Plan` whenever the strategy itself changes.
29//! Libra should not mutate a stored plan to reflect execution progress;
30//! instead it should append `PlanStepEvent` objects and keep the active
31//! plan head in scheduler state.
32
33use std::fmt;
34
35use serde::{Deserialize, Serialize};
36use uuid::Uuid;
37
38use crate::{
39    errors::GitError,
40    hash::ObjectHash,
41    internal::object::{
42        ObjectTrait,
43        types::{ActorRef, Header, ObjectType},
44    },
45};
46
47/// Immutable step definition inside a `Plan`.
48///
49/// `PlanStep` describes what a logical step is supposed to do. Runtime
50/// facts for that step belong to `PlanStepEvent`, not to this struct.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(deny_unknown_fields)]
53pub struct PlanStep {
54    /// Stable logical step identity across Plan revisions.
55    ///
56    /// `step_id` is the cross-revision identity for the logical step.
57    /// One concrete stored step snapshot is identified by the pair
58    /// `(Plan.header.object_id(), step_id)`.
59    step_id: Uuid,
60    /// Human-readable description of what this step is supposed to do.
61    description: String,
62    /// Optional structured inputs expected by this step definition.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    inputs: Option<serde_json::Value>,
65    /// Optional structured checks or completion criteria for this step.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    checks: Option<serde_json::Value>,
68}
69
70impl PlanStep {
71    /// Create a new logical plan step with a fresh stable step id.
72    pub fn new(description: impl Into<String>) -> Self {
73        Self {
74            step_id: Uuid::now_v7(),
75            description: description.into(),
76            inputs: None,
77            checks: None,
78        }
79    }
80
81    /// Return the stable logical step id.
82    pub fn step_id(&self) -> Uuid {
83        self.step_id
84    }
85
86    /// Return the human-readable step description.
87    pub fn description(&self) -> &str {
88        &self.description
89    }
90
91    /// Return the structured input contract for this step, if present.
92    pub fn inputs(&self) -> Option<&serde_json::Value> {
93        self.inputs.as_ref()
94    }
95
96    /// Return the structured checks for this step, if present.
97    pub fn checks(&self) -> Option<&serde_json::Value> {
98        self.checks.as_ref()
99    }
100
101    /// Set or clear the structured inputs for this in-memory step.
102    pub fn set_inputs(&mut self, inputs: Option<serde_json::Value>) {
103        self.inputs = inputs;
104    }
105
106    /// Set or clear the structured checks for this in-memory step.
107    pub fn set_checks(&mut self, checks: Option<serde_json::Value>) {
108        self.checks = checks;
109    }
110}
111
112/// Immutable planning revision for one `Intent`.
113///
114/// A `Plan` may form a DAG through `parents`, allowing Libra to model
115/// linear replanning as well as multi-branch plan merges without losing
116/// history.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(deny_unknown_fields)]
119pub struct Plan {
120    /// Common object header carrying the immutable object id, type,
121    /// creator, and timestamps.
122    #[serde(flatten)]
123    header: Header,
124    /// Canonical owning intent for this planning revision.
125    intent: Uuid,
126    /// Parent plan revisions from which this plan directly derives.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    parents: Vec<Uuid>,
129    /// Immutable planning-time context-frame snapshot used for plan
130    /// derivation.
131    ///
132    /// This is distinct from `Intent.analysis_context_frames`, which
133    /// captures prompt-analysis context used while deriving the
134    /// `IntentSpec`.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    context_frames: Vec<Uuid>,
137    /// Immutable step structure chosen for this plan revision.
138    #[serde(default)]
139    steps: Vec<PlanStep>,
140}
141
142impl Plan {
143    /// Create a new root plan revision for the given intent.
144    pub fn new(created_by: ActorRef, intent: Uuid) -> Result<Self, String> {
145        Ok(Self {
146            header: Header::new(ObjectType::Plan, created_by)?,
147            intent,
148            parents: Vec::new(),
149            context_frames: Vec::new(),
150            steps: Vec::new(),
151        })
152    }
153
154    /// Create a new child plan revision from this plan as the only
155    /// parent.
156    pub fn new_revision(&self, created_by: ActorRef) -> Result<Self, String> {
157        Self::new_revision_chain(created_by, &[self])
158    }
159
160    /// Create a new plan revision from a single explicit parent.
161    pub fn new_revision_from(created_by: ActorRef, parent: &Self) -> Result<Self, String> {
162        Self::new_revision_chain(created_by, &[parent])
163    }
164
165    /// Create a new plan revision from multiple parents.
166    ///
167    /// All parents must belong to the same intent.
168    pub fn new_revision_chain(created_by: ActorRef, parents: &[&Self]) -> Result<Self, String> {
169        let first_parent = parents
170            .first()
171            .ok_or_else(|| "plan revision chain requires at least one parent".to_string())?;
172        let mut plan = Self::new(created_by, first_parent.intent)?;
173        for parent in parents {
174            if parent.intent != first_parent.intent {
175                return Err(format!(
176                    "plan parents must belong to the same intent: expected {}, got {}",
177                    first_parent.intent, parent.intent
178                ));
179            }
180            plan.add_parent(parent.header.object_id());
181        }
182        Ok(plan)
183    }
184
185    /// Return the immutable header for this plan revision.
186    pub fn header(&self) -> &Header {
187        &self.header
188    }
189
190    /// Return the canonical owning intent id.
191    pub fn intent(&self) -> Uuid {
192        self.intent
193    }
194
195    /// Return the direct parent plan ids.
196    pub fn parents(&self) -> &[Uuid] {
197        &self.parents
198    }
199
200    /// Add one parent link if it is not already present and is not self.
201    pub fn add_parent(&mut self, parent_id: Uuid) {
202        if parent_id == self.header.object_id() {
203            return;
204        }
205        if !self.parents.contains(&parent_id) {
206            self.parents.push(parent_id);
207        }
208    }
209
210    /// Replace the parent set for this in-memory plan revision.
211    pub fn set_parents(&mut self, parents: Vec<Uuid>) {
212        self.parents = parents;
213    }
214
215    /// Return the planning-time context frame ids frozen into this plan.
216    pub fn context_frames(&self) -> &[Uuid] {
217        &self.context_frames
218    }
219
220    /// Replace the planning-time context frame set for this in-memory
221    /// plan revision.
222    pub fn set_context_frames(&mut self, context_frames: Vec<Uuid>) {
223        self.context_frames = context_frames;
224    }
225
226    /// Return the immutable step definitions stored in this plan.
227    pub fn steps(&self) -> &[PlanStep] {
228        &self.steps
229    }
230
231    /// Append one logical step definition to this in-memory plan.
232    pub fn add_step(&mut self, step: PlanStep) {
233        self.steps.push(step);
234    }
235}
236
237impl fmt::Display for Plan {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "Plan: {}", self.header.object_id())
240    }
241}
242
243impl ObjectTrait for Plan {
244    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
245    where
246        Self: Sized,
247    {
248        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
249    }
250
251    fn get_type(&self) -> ObjectType {
252        ObjectType::Plan
253    }
254
255    fn get_size(&self) -> usize {
256        match serde_json::to_vec(self) {
257            Ok(v) => v.len(),
258            Err(e) => {
259                tracing::warn!("failed to compute Plan size: {}", e);
260                0
261            }
262        }
263    }
264
265    fn to_data(&self) -> Result<Vec<u8>, GitError> {
266        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use serde_json::json;
273
274    use super::*;
275
276    // Coverage:
277    // - single-parent and multi-parent plan revision DAG behaviour
278    // - parent deduplication and self-link rejection
279    // - mixed-intent merge rejection
280    // - plan-level frozen context frame assignment
281    // - serde compatibility for step descriptions
282
283    #[test]
284    fn test_plan_revision_graph() {
285        let actor = ActorRef::human("jackie").expect("actor");
286        let intent_id = Uuid::from_u128(0x10);
287        let plan_v1 = Plan::new(actor.clone(), intent_id).expect("plan");
288        let plan_v2 = plan_v1.new_revision(actor.clone()).expect("plan");
289        let plan_v2b = Plan::new_revision_from(actor.clone(), &plan_v1).expect("plan");
290        let plan_v3 = Plan::new_revision_chain(actor, &[&plan_v2, &plan_v2b]).expect("plan");
291
292        assert!(plan_v1.parents().is_empty());
293        assert_eq!(plan_v2.parents(), &[plan_v1.header().object_id()]);
294        assert_eq!(
295            plan_v3.parents(),
296            &[plan_v2.header().object_id(), plan_v2b.header().object_id()]
297        );
298        assert_eq!(plan_v3.intent(), intent_id);
299    }
300
301    #[test]
302    fn test_plan_add_parent_dedupes_and_ignores_self() {
303        let actor = ActorRef::human("jackie").expect("actor");
304        let mut plan = Plan::new(actor, Uuid::from_u128(0x11)).expect("plan");
305        let parent_a = Uuid::from_u128(0x41);
306        let parent_b = Uuid::from_u128(0x42);
307
308        plan.add_parent(parent_a);
309        plan.add_parent(parent_a);
310        plan.add_parent(parent_b);
311        plan.add_parent(plan.header().object_id());
312
313        assert_eq!(plan.parents(), &[parent_a, parent_b]);
314    }
315
316    #[test]
317    fn test_plan_revision_chain_rejects_mixed_intents() {
318        let actor = ActorRef::human("jackie").expect("actor");
319        let plan_a = Plan::new(actor.clone(), Uuid::from_u128(0x100)).expect("plan");
320        let plan_b = Plan::new(actor, Uuid::from_u128(0x200)).expect("plan");
321
322        let err = Plan::new_revision_chain(
323            ActorRef::human("jackie").expect("actor"),
324            &[&plan_a, &plan_b],
325        )
326        .expect_err("mixed intents should fail");
327
328        assert!(err.contains("same intent"));
329    }
330
331    #[test]
332    fn test_plan_context_frames() {
333        let actor = ActorRef::human("jackie").expect("actor");
334        let mut plan = Plan::new(actor, Uuid::from_u128(0x12)).expect("plan");
335        let frame_a = Uuid::from_u128(0x51);
336        let frame_b = Uuid::from_u128(0x52);
337
338        plan.set_context_frames(vec![frame_a, frame_b]);
339        assert_eq!(plan.context_frames(), &[frame_a, frame_b]);
340    }
341
342    #[test]
343    fn test_plan_step_serializes_description_field() {
344        let step = PlanStep::new("run tests");
345        let value = serde_json::to_value(&step).expect("serialize step");
346
347        assert_eq!(
348            value.get("description").and_then(|v| v.as_str()),
349            Some("run tests")
350        );
351        assert!(value.get("step_id").is_some());
352    }
353
354    #[test]
355    fn test_plan_step_deserializes_description_field() {
356        let step_id = Uuid::from_u128(0x501);
357        let step: PlanStep = serde_json::from_value(json!({
358            "step_id": step_id,
359            "description": "run tests"
360        }))
361        .expect("deserialize step");
362
363        assert_eq!(step.step_id(), step_id);
364        assert_eq!(step.description(), "run tests");
365    }
366}