perspt_agent/
ledger.rs

1//! DuckDB Merkle Ledger
2//!
3//! Persistent storage for session history, commits, and Merkle proofs.
4
5use anyhow::{Context, Result};
6pub use perspt_store::{LlmRequestRecord, NodeStateRecord, SessionRecord, SessionStore};
7use std::path::{Path, PathBuf};
8
9/// Merkle commit record (Legacy wrapper for compatibility)
10#[derive(Debug, Clone)]
11pub struct MerkleCommit {
12    pub commit_id: String,
13    pub session_id: String,
14    pub node_id: String,
15    pub merkle_root: [u8; 32],
16    pub parent_hash: Option<[u8; 32]>,
17    pub timestamp: i64,
18    pub energy: f32,
19    pub stable: bool,
20}
21
22/// Session record (Legacy wrapper for compatibility)
23#[derive(Debug, Clone)]
24pub struct SessionRecordLegacy {
25    pub session_id: String,
26    pub task: String,
27    pub started_at: i64,
28    pub ended_at: Option<i64>,
29    pub status: String,
30    pub total_nodes: usize,
31    pub completed_nodes: usize,
32}
33
34/// Merkle Ledger using DuckDB for persistence
35pub struct MerkleLedger {
36    /// Session store from perspt-store
37    store: SessionStore,
38    /// Current session metadata (legacy cache)
39    current_session: Option<SessionRecordLegacy>,
40    /// Session artifact directory
41    session_dir: Option<PathBuf>,
42}
43
44impl MerkleLedger {
45    /// Create a new ledger (opens or creates database)
46    pub fn new() -> Result<Self> {
47        let store = SessionStore::new().context("Failed to initialize session store")?;
48        Ok(Self {
49            store,
50            current_session: None,
51            session_dir: None,
52        })
53    }
54
55    /// Create an in-memory ledger (for testing)
56    pub fn in_memory() -> Result<Self> {
57        // Use a unique temp db for testing to avoid collisions
58        let temp_dir = std::env::temp_dir();
59        let db_path = temp_dir.join(format!("perspt_test_{}.db", uuid::Uuid::new_v4()));
60        let store = SessionStore::open(&db_path)?;
61        Ok(Self {
62            store,
63            current_session: None,
64            session_dir: None,
65        })
66    }
67
68    /// Start a new session
69    pub fn start_session(&mut self, session_id: &str, task: &str, working_dir: &str) -> Result<()> {
70        let record = SessionRecord {
71            session_id: session_id.to_string(),
72            task: task.to_string(),
73            working_dir: working_dir.to_string(),
74            merkle_root: None,
75            detected_toolchain: None,
76            status: "RUNNING".to_string(),
77        };
78
79        self.store.create_session(&record)?;
80
81        // Create physical artifact directory
82        let dir = self.store.create_session_dir(session_id)?;
83        self.session_dir = Some(dir);
84
85        let legacy_record = SessionRecordLegacy {
86            session_id: session_id.to_string(),
87            task: task.to_string(),
88            started_at: chrono_timestamp(),
89            ended_at: None,
90            status: "RUNNING".to_string(),
91            total_nodes: 0,
92            completed_nodes: 0,
93        };
94        self.current_session = Some(legacy_record);
95
96        log::info!("Started persistent session: {}", session_id);
97        Ok(())
98    }
99
100    /// Record energy measurement
101    pub fn record_energy(
102        &self,
103        node_id: &str,
104        energy: &crate::types::EnergyComponents,
105        total_energy: f32,
106    ) -> Result<()> {
107        let session_id = self
108            .current_session
109            .as_ref()
110            .map(|s| s.session_id.clone())
111            .context("No active session to record energy")?;
112
113        let record = perspt_store::EnergyRecord {
114            node_id: node_id.to_string(),
115            session_id,
116            v_syn: energy.v_syn,
117            v_str: energy.v_str,
118            v_log: energy.v_log,
119            v_boot: energy.v_boot,
120            v_sheaf: energy.v_sheaf,
121            v_total: total_energy,
122        };
123
124        self.store.record_energy(&record)?;
125        Ok(())
126    }
127
128    /// Commit a stable node state
129    pub fn commit_node(
130        &mut self,
131        node_id: &str,
132        merkle_root: [u8; 32],
133        _parent_hash: Option<[u8; 32]>,
134        energy: f32,
135        state_json: String,
136    ) -> Result<String> {
137        let session_id = self
138            .current_session
139            .as_ref()
140            .map(|s| s.session_id.clone())
141            .context("No active session to commit")?;
142
143        let commit_id = generate_commit_id();
144
145        let record = NodeStateRecord {
146            node_id: node_id.to_string(),
147            session_id: session_id.clone(),
148            state: state_json,
149            v_total: energy,
150            merkle_hash: Some(merkle_root.to_vec()),
151            attempt_count: 1, // Placeholder
152        };
153
154        self.store.record_node_state(&record)?;
155        self.store.update_merkle_root(&session_id, &merkle_root)?;
156
157        log::info!("Committed node {} to store", node_id);
158
159        // Update session progress
160        if let Some(ref mut session) = self.current_session {
161            session.completed_nodes += 1;
162        }
163
164        Ok(commit_id)
165    }
166
167    /// End the current session
168    pub fn end_session(&mut self, status: &str) -> Result<()> {
169        if let Some(ref mut session) = self.current_session {
170            session.ended_at = Some(chrono_timestamp());
171            session.status = status.to_string();
172            log::info!(
173                "Ended session {} with status: {}",
174                session.session_id,
175                status
176            );
177        }
178        Ok(())
179    }
180
181    /// Get artifacts directory
182    pub fn artifacts_dir(&self) -> Option<&Path> {
183        self.session_dir.as_deref()
184    }
185
186    /// Get session statistics (legacy facade)
187    pub fn get_stats(&self) -> LedgerStats {
188        LedgerStats {
189            total_sessions: 0, // Would query store.count_sessions()
190            total_commits: 0,
191            db_size_bytes: 0,
192        }
193    }
194
195    /// Get the current merkle root (legacy facade)
196    pub fn current_merkle_root(&self) -> [u8; 32] {
197        [0u8; 32] // Placeholder
198    }
199
200    /// Record an LLM request/response for debugging and cost tracking
201    pub fn record_llm_request(
202        &self,
203        model: &str,
204        prompt: &str,
205        response: &str,
206        node_id: Option<&str>,
207        latency_ms: i32,
208    ) -> Result<()> {
209        let session_id = self
210            .current_session
211            .as_ref()
212            .map(|s| s.session_id.clone())
213            .context("No active session to record LLM request")?;
214
215        let record = LlmRequestRecord {
216            session_id,
217            node_id: node_id.map(|s| s.to_string()),
218            model: model.to_string(),
219            prompt: prompt.to_string(),
220            response: response.to_string(),
221            tokens_in: 0, // TODO: Extract from provider response if available
222            tokens_out: 0,
223            latency_ms,
224        };
225
226        self.store.record_llm_request(&record)?;
227        log::debug!(
228            "Recorded LLM request: model={}, prompt_len={}, response_len={}",
229            model,
230            prompt.len(),
231            response.len()
232        );
233        Ok(())
234    }
235
236    /// Get access to the underlying store (for direct queries)
237    pub fn store(&self) -> &SessionStore {
238        &self.store
239    }
240}
241
242/// Ledger statistics (Legacy)
243#[derive(Debug, Clone)]
244pub struct LedgerStats {
245    pub total_sessions: usize,
246    pub total_commits: usize,
247    pub db_size_bytes: u64,
248}
249
250/// Generate a unique commit ID
251fn generate_commit_id() -> String {
252    use std::time::{SystemTime, UNIX_EPOCH};
253    let now = SystemTime::now()
254        .duration_since(UNIX_EPOCH)
255        .unwrap()
256        .as_nanos();
257    format!("{:x}", now)
258}
259
260/// Get current timestamp
261fn chrono_timestamp() -> i64 {
262    use std::time::{SystemTime, UNIX_EPOCH};
263    SystemTime::now()
264        .duration_since(UNIX_EPOCH)
265        .unwrap()
266        .as_secs() as i64
267}