Skip to main content

agentic_planning/
file_format.rs

1use crate::{error::Result, Error, PlanningEngine};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7pub const APLAN_MAGIC: [u8; 4] = *b"PLAN";
8pub const APLAN_VERSION: u16 = 1;
9#[allow(dead_code)]
10pub const APLAN_HEADER_SIZE: usize = 128;
11#[allow(dead_code)]
12pub const APLAN_FOOTER_SIZE: usize = 64;
13pub const APLAN_INTEGRITY_MARKER: [u8; 8] = *b"PLANEND\0";
14
15#[repr(C, packed)]
16#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
17pub struct AplanHeader {
18    pub magic: [u8; 4],
19    pub version: u16,
20    pub flags: u32,
21    pub created_at: i64,
22    pub modified_at: i64,
23    pub goal_count: u32,
24    pub decision_count: u32,
25    pub commitment_count: u32,
26    pub dream_count: u32,
27    pub federation_count: u32,
28    pub goal_section_offset: u64,
29    pub decision_section_offset: u64,
30    pub commitment_section_offset: u64,
31    pub dream_section_offset: u64,
32    pub federation_section_offset: u64,
33    pub index_section_offset: u64,
34    pub checksum: [u8; 32],
35    pub reserved: [u8; 14],
36}
37
38#[repr(C, packed)]
39#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
40pub struct AplanFooter {
41    pub file_size: u64,
42    pub write_count: u64,
43    pub last_session: [u8; 16],
44    pub integrity: [u8; 8],
45    pub footer_checksum: [u8; 16],
46    pub reserved: [u8; 8],
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50struct PersistedPlan {
51    header: AplanHeader,
52    goals: std::collections::HashMap<crate::GoalId, crate::Goal>,
53    decisions: std::collections::HashMap<crate::DecisionId, crate::Decision>,
54    commitments: std::collections::HashMap<crate::CommitmentId, crate::Commitment>,
55    dreams: std::collections::HashMap<crate::DreamId, crate::Dream>,
56    federations: std::collections::HashMap<crate::FederationId, crate::Federation>,
57    soul_archive: std::collections::HashMap<crate::GoalId, crate::GoalSoulArchive>,
58    indexes: crate::PlanIndexes,
59    footer: AplanFooter,
60}
61
62fn canonicalize_json(value: Value) -> Value {
63    match value {
64        Value::Object(obj) => {
65            let mut sorted_keys: Vec<_> = obj.keys().cloned().collect();
66            sorted_keys.sort();
67            let mut canonical = Map::new();
68            for key in sorted_keys {
69                if let Some(v) = obj.get(&key) {
70                    canonical.insert(key, canonicalize_json(v.clone()));
71                }
72            }
73            Value::Object(canonical)
74        }
75        Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_json).collect()),
76        other => other,
77    }
78}
79
80impl PlanningEngine {
81    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
82        let path = path.as_ref();
83        let tmp_path = temp_path_for(path);
84
85        // R4: Crash recovery — if .aplan.tmp exists but .aplan does not, recover
86        if !path.exists() && tmp_path.exists() {
87            std::fs::rename(&tmp_path, path)?;
88        }
89        // If both exist, use .aplan (the completed write); clean up stale tmp
90        if path.exists() && tmp_path.exists() {
91            let _ = std::fs::remove_file(&tmp_path);
92        }
93
94        if path.exists() {
95            Self::load(path)
96        } else {
97            Self::create(path)
98        }
99    }
100
101    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
102        let mut engine = Self::in_memory();
103        engine.path = Some(path.as_ref().to_path_buf());
104        engine.save()?;
105        Ok(engine)
106    }
107
108    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
109        let path_ref = path.as_ref();
110        let bytes = std::fs::read(path_ref)?;
111        let persisted: PersistedPlan = serde_json::from_slice(&bytes)?;
112
113        if persisted.header.magic != APLAN_MAGIC || persisted.header.version != APLAN_VERSION {
114            return Err(Error::InvalidFile);
115        }
116        if persisted.footer.integrity != APLAN_INTEGRITY_MARKER {
117            return Err(Error::InvalidFile);
118        }
119
120        // R2: Checksum verification — use BTreeMap for deterministic serialization order
121        let is_legacy = persisted.header.checksum == [0u8; 32];
122        if !is_legacy {
123            let sorted_goals: BTreeMap<_, _> = persisted.goals.iter().collect();
124            let sorted_decisions: BTreeMap<_, _> = persisted.decisions.iter().collect();
125            let sorted_commitments: BTreeMap<_, _> = persisted.commitments.iter().collect();
126            let sorted_dreams: BTreeMap<_, _> = persisted.dreams.iter().collect();
127            let sorted_federations: BTreeMap<_, _> = persisted.federations.iter().collect();
128            let sorted_souls: BTreeMap<_, _> = persisted.soul_archive.iter().collect();
129            let canonical_indexes = canonicalize_json(serde_json::to_value(&persisted.indexes)?);
130
131            let encoded_state = serde_json::to_vec(&(
132                &sorted_goals,
133                &sorted_decisions,
134                &sorted_commitments,
135                &sorted_dreams,
136                &sorted_federations,
137                &sorted_souls,
138                canonical_indexes,
139            ))?;
140            let computed = blake3::hash(&encoded_state);
141            if *computed.as_bytes() != persisted.header.checksum {
142                return Err(Error::CorruptedFile(format!(
143                    "checksum mismatch: expected {:?}, computed {:?}",
144                    &persisted.header.checksum[..4],
145                    &computed.as_bytes()[..4]
146                )));
147            }
148        }
149
150        let mut engine = Self::in_memory();
151        engine.path = Some(path_ref.to_path_buf());
152        engine.goal_store = persisted.goals;
153        engine.decision_store = persisted.decisions;
154        engine.commitment_store = persisted.commitments;
155        engine.dream_store = persisted.dreams;
156        engine.federation_store = persisted.federations;
157        engine.soul_archive = persisted.soul_archive;
158        engine.indexes = persisted.indexes;
159        engine.write_count = persisted.footer.write_count;
160        engine.dirty = false;
161        Ok(engine)
162    }
163
164    pub fn save(&mut self) -> Result<()> {
165        let Some(path) = self.path.clone() else {
166            return Ok(());
167        };
168
169        // R5: File-level locking — prevent concurrent writes to the same .aplan file
170        let _file_lock = crate::locking::FileLock::acquire(&path).map_err(|e| {
171            Error::Io(std::io::Error::new(
172                std::io::ErrorKind::WouldBlock,
173                format!("failed to acquire file lock: {e}"),
174            ))
175        })?;
176
177        let now = crate::Timestamp::now().0;
178
179        // R1: Increment write_count
180        self.write_count += 1;
181
182        // R2: Use BTreeMap for deterministic serialization order (HashMap is non-deterministic)
183        let sorted_goals: BTreeMap<_, _> = self.goal_store.iter().collect();
184        let sorted_decisions: BTreeMap<_, _> = self.decision_store.iter().collect();
185        let sorted_commitments: BTreeMap<_, _> = self.commitment_store.iter().collect();
186        let sorted_dreams: BTreeMap<_, _> = self.dream_store.iter().collect();
187        let sorted_federations: BTreeMap<_, _> = self.federation_store.iter().collect();
188        let sorted_souls: BTreeMap<_, _> = self.soul_archive.iter().collect();
189        let canonical_indexes = canonicalize_json(serde_json::to_value(&self.indexes)?);
190
191        let encoded_state = serde_json::to_vec(&(
192            &sorted_goals,
193            &sorted_decisions,
194            &sorted_commitments,
195            &sorted_dreams,
196            &sorted_federations,
197            &sorted_souls,
198            canonical_indexes,
199        ))?;
200
201        // R1: Compute real blake3 checksum of payload
202        let digest = blake3::hash(&encoded_state);
203
204        // R3: Compute section offsets (estimated from serialization order)
205        // These are approximate — computed from cumulative serialized sizes
206        let goal_bytes = serde_json::to_vec(&sorted_goals)?;
207        let decision_bytes = serde_json::to_vec(&sorted_decisions)?;
208        let commitment_bytes = serde_json::to_vec(&sorted_commitments)?;
209        let dream_bytes = serde_json::to_vec(&sorted_dreams)?;
210        let federation_bytes = serde_json::to_vec(&sorted_federations)?;
211
212        let mut offset: u64 = 0;
213        let goal_offset = offset;
214        offset += goal_bytes.len() as u64;
215        let decision_offset = offset;
216        offset += decision_bytes.len() as u64;
217        let commitment_offset = offset;
218        offset += commitment_bytes.len() as u64;
219        let dream_offset = offset;
220        offset += dream_bytes.len() as u64;
221        let federation_offset = offset;
222        offset += federation_bytes.len() as u64;
223        let index_offset = offset;
224
225        let header = AplanHeader {
226            magic: APLAN_MAGIC,
227            version: APLAN_VERSION,
228            flags: 0,
229            created_at: now,
230            modified_at: now,
231            goal_count: self.goal_store.len() as u32,
232            decision_count: self.decision_store.len() as u32,
233            commitment_count: self.commitment_store.len() as u32,
234            dream_count: self.dream_store.len() as u32,
235            federation_count: self.federation_store.len() as u32,
236            goal_section_offset: goal_offset,
237            decision_section_offset: decision_offset,
238            commitment_section_offset: commitment_offset,
239            dream_section_offset: dream_offset,
240            federation_section_offset: federation_offset,
241            index_section_offset: index_offset,
242            checksum: *digest.as_bytes(),
243            reserved: [0; 14],
244        };
245
246        // R1: Set last_session from engine session state and compute footer checksum
247        let session_bytes = *self.session_id.as_bytes();
248        let footer_payload = [
249            &(encoded_state.len() as u64).to_le_bytes()[..],
250            &self.write_count.to_le_bytes(),
251            &session_bytes,
252        ]
253        .concat();
254        let footer_hash = blake3::hash(&footer_payload);
255
256        let footer = AplanFooter {
257            file_size: encoded_state.len() as u64,
258            write_count: self.write_count,
259            last_session: session_bytes,
260            integrity: APLAN_INTEGRITY_MARKER,
261            footer_checksum: footer_hash.as_bytes()[..16].try_into().unwrap_or([0; 16]),
262            reserved: [0; 8],
263        };
264
265        let persisted = PersistedPlan {
266            header,
267            goals: self.goal_store.clone(),
268            decisions: self.decision_store.clone(),
269            commitments: self.commitment_store.clone(),
270            dreams: self.dream_store.clone(),
271            federations: self.federation_store.clone(),
272            soul_archive: self.soul_archive.clone(),
273            indexes: self.indexes.clone(),
274            footer,
275        };
276
277        let data = serde_json::to_vec_pretty(&persisted)?;
278
279        // R4: Atomic write — write to temp, fsync, rename
280        let temp_path = temp_path_for(&path);
281        let temp_file = std::fs::File::create(&temp_path)?;
282        use std::io::Write;
283        let mut writer = std::io::BufWriter::new(temp_file);
284        writer.write_all(&data)?;
285        writer.flush()?;
286        writer.get_ref().sync_all()?;
287        drop(writer);
288
289        std::fs::rename(&temp_path, &path)?;
290
291        self.dirty = false;
292        Ok(())
293    }
294}
295
296fn temp_path_for(path: &Path) -> PathBuf {
297    let mut temp = path.to_path_buf();
298    temp.set_extension("aplan.tmp");
299    temp
300}