1use anyhow::{Context, Result};
6pub use perspt_store::{LlmRequestRecord, NodeStateRecord, SessionRecord, SessionStore};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
15pub struct NodeCommitPayload {
16 pub node_id: String,
17 pub state: String,
18 pub v_total: f32,
19 pub merkle_hash: Option<Vec<u8>>,
20 pub attempt_count: i32,
21 pub node_class: Option<String>,
22 pub owner_plugin: Option<String>,
23 pub goal: Option<String>,
24 pub parent_id: Option<String>,
25 pub children: Option<String>,
27 pub last_error_type: Option<String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct MerkleCommit {
33 pub commit_id: String,
34 pub session_id: String,
35 pub node_id: String,
36 pub merkle_root: [u8; 32],
37 pub parent_hash: Option<[u8; 32]>,
38 pub timestamp: i64,
39 pub energy: f32,
40 pub stable: bool,
41}
42
43#[derive(Debug, Clone)]
45pub struct SessionRecordLegacy {
46 pub session_id: String,
47 pub task: String,
48 pub started_at: i64,
49 pub ended_at: Option<i64>,
50 pub status: String,
51 pub total_nodes: usize,
52 pub completed_nodes: usize,
53}
54
55pub struct MerkleLedger {
57 store: SessionStore,
59 pub(crate) current_session: Option<SessionRecordLegacy>,
61 session_dir: Option<PathBuf>,
63}
64
65impl MerkleLedger {
66 pub fn new() -> Result<Self> {
68 let store = SessionStore::new().context("Failed to initialize session store")?;
69 Ok(Self {
70 store,
71 current_session: None,
72 session_dir: None,
73 })
74 }
75
76 pub fn in_memory() -> Result<Self> {
78 let temp_dir = std::env::temp_dir();
80 let db_path = temp_dir.join(format!("perspt_test_{}.db", uuid::Uuid::new_v4()));
81 let store = SessionStore::open(&db_path)?;
82 Ok(Self {
83 store,
84 current_session: None,
85 session_dir: None,
86 })
87 }
88
89 pub fn start_session(&mut self, session_id: &str, task: &str, working_dir: &str) -> Result<()> {
91 let record = SessionRecord {
92 session_id: session_id.to_string(),
93 task: task.to_string(),
94 working_dir: working_dir.to_string(),
95 merkle_root: None,
96 detected_toolchain: None,
97 status: "RUNNING".to_string(),
98 };
99
100 self.store.create_session(&record)?;
101
102 let dir = self.store.create_session_dir(session_id)?;
104 self.session_dir = Some(dir);
105
106 let legacy_record = SessionRecordLegacy {
107 session_id: session_id.to_string(),
108 task: task.to_string(),
109 started_at: chrono_timestamp(),
110 ended_at: None,
111 status: "RUNNING".to_string(),
112 total_nodes: 0,
113 completed_nodes: 0,
114 };
115 self.current_session = Some(legacy_record);
116
117 log::info!("Started persistent session: {}", session_id);
118 Ok(())
119 }
120
121 pub fn record_energy(
123 &self,
124 node_id: &str,
125 energy: &crate::types::EnergyComponents,
126 total_energy: f32,
127 ) -> Result<()> {
128 let session_id = self
129 .current_session
130 .as_ref()
131 .map(|s| s.session_id.clone())
132 .context("No active session to record energy")?;
133
134 let record = perspt_store::EnergyRecord {
135 node_id: node_id.to_string(),
136 session_id,
137 v_syn: energy.v_syn,
138 v_str: energy.v_str,
139 v_log: energy.v_log,
140 v_boot: energy.v_boot,
141 v_sheaf: energy.v_sheaf,
142 v_total: total_energy,
143 };
144
145 self.store.record_energy(&record)?;
146 Ok(())
147 }
148
149 pub fn commit_node(
151 &mut self,
152 node_id: &str,
153 merkle_root: [u8; 32],
154 _parent_hash: Option<[u8; 32]>,
155 energy: f32,
156 state_json: String,
157 ) -> Result<String> {
158 let session_id = self
159 .current_session
160 .as_ref()
161 .map(|s| s.session_id.clone())
162 .context("No active session to commit")?;
163
164 let commit_id = generate_commit_id();
165
166 let record = NodeStateRecord {
167 node_id: node_id.to_string(),
168 session_id: session_id.clone(),
169 state: state_json,
170 v_total: energy,
171 merkle_hash: Some(merkle_root.to_vec()),
172 attempt_count: 1, node_class: None,
175 owner_plugin: None,
176 goal: None,
177 parent_id: None,
178 children: None,
179 last_error_type: None,
180 committed_at: None,
181 };
182
183 self.store.record_node_state(&record)?;
184 self.store.update_merkle_root(&session_id, &merkle_root)?;
185
186 log::info!("Committed node {} to store", node_id);
187
188 if let Some(ref mut session) = self.current_session {
190 session.completed_nodes += 1;
191 }
192
193 Ok(commit_id)
194 }
195
196 pub fn commit_node_snapshot(&mut self, payload: &NodeCommitPayload) -> Result<String> {
202 let session_id = self
203 .current_session
204 .as_ref()
205 .map(|s| s.session_id.clone())
206 .context("No active session to commit")?;
207
208 let commit_id = generate_commit_id();
209
210 let record = NodeStateRecord {
211 node_id: payload.node_id.clone(),
212 session_id: session_id.clone(),
213 state: payload.state.clone(),
214 v_total: payload.v_total,
215 merkle_hash: payload.merkle_hash.clone(),
216 attempt_count: payload.attempt_count,
217 node_class: payload.node_class.clone(),
218 owner_plugin: payload.owner_plugin.clone(),
219 goal: payload.goal.clone(),
220 parent_id: payload.parent_id.clone(),
221 children: payload.children.clone(),
222 last_error_type: payload.last_error_type.clone(),
223 committed_at: Some(chrono_iso_now()),
224 };
225
226 self.store.record_node_state(&record)?;
227
228 if let Some(ref hash) = payload.merkle_hash {
230 if hash.len() == 32 {
231 let mut root = [0u8; 32];
232 root.copy_from_slice(hash);
233 self.store.update_merkle_root(&session_id, &root)?;
234 }
235 }
236
237 log::info!(
238 "Committed node snapshot '{}' (state={}, attempts={})",
239 payload.node_id,
240 payload.state,
241 payload.attempt_count
242 );
243
244 if let Some(ref mut session) = self.current_session {
245 session.completed_nodes += 1;
246 }
247
248 Ok(commit_id)
249 }
250
251 pub fn end_session(&mut self, status: &str) -> Result<()> {
253 if let Some(ref mut session) = self.current_session {
254 session.ended_at = Some(chrono_timestamp());
255 session.status = status.to_string();
256 self.store
258 .update_session_status(&session.session_id, status)?;
259 log::info!(
260 "Ended session {} with status: {}",
261 session.session_id,
262 status
263 );
264 }
265 Ok(())
266 }
267
268 pub fn artifacts_dir(&self) -> Option<&Path> {
270 self.session_dir.as_deref()
271 }
272
273 pub fn get_stats(&self) -> LedgerStats {
275 LedgerStats {
276 total_sessions: 0, total_commits: 0,
278 db_size_bytes: 0,
279 }
280 }
281
282 pub fn current_merkle_root(&self) -> [u8; 32] {
284 [0u8; 32] }
286
287 #[allow(clippy::too_many_arguments)]
289 pub fn record_llm_request(
290 &self,
291 model: &str,
292 prompt: &str,
293 response: &str,
294 node_id: Option<&str>,
295 latency_ms: i32,
296 tokens_in: i32,
297 tokens_out: i32,
298 ) -> Result<()> {
299 let session_id = self
300 .current_session
301 .as_ref()
302 .map(|s| s.session_id.clone())
303 .context("No active session to record LLM request")?;
304
305 let record = LlmRequestRecord {
306 session_id,
307 node_id: node_id.map(|s| s.to_string()),
308 model: model.to_string(),
309 prompt: prompt.to_string(),
310 response: response.to_string(),
311 tokens_in,
312 tokens_out,
313 latency_ms,
314 };
315
316 self.store.record_llm_request(&record)?;
317 log::debug!(
318 "Recorded LLM request: model={}, prompt_len={}, response_len={}",
319 model,
320 prompt.len(),
321 response.len()
322 );
323 Ok(())
324 }
325
326 pub fn record_llm_usage(
331 &self,
332 model: &str,
333 node_id: Option<&str>,
334 latency_ms: i32,
335 tokens_in: i32,
336 tokens_out: i32,
337 ) -> Result<()> {
338 let session_id = self
339 .current_session
340 .as_ref()
341 .map(|s| s.session_id.clone())
342 .context("No active session to record LLM usage")?;
343
344 let record = LlmRequestRecord {
346 session_id,
347 node_id: node_id.map(|s| s.to_string()),
348 model: model.to_string(),
349 prompt: String::new(),
350 response: String::new(),
351 tokens_in,
352 tokens_out,
353 latency_ms,
354 };
355
356 self.store.record_llm_request(&record)?;
357 log::debug!(
358 "Recorded LLM usage: model={}, tokens_in={}, tokens_out={}, latency={}ms",
359 model,
360 tokens_in,
361 tokens_out,
362 latency_ms,
363 );
364 Ok(())
365 }
366
367 pub fn store(&self) -> &SessionStore {
369 &self.store
370 }
371
372 pub fn record_structural_digest(
378 &self,
379 node_id: &str,
380 source_path: &str,
381 artifact_kind: &str,
382 hash: &[u8],
383 version: i32,
384 ) -> Result<()> {
385 let session_id = self
386 .current_session
387 .as_ref()
388 .map(|s| s.session_id.clone())
389 .context("No active session to record structural digest")?;
390
391 let record = perspt_store::StructuralDigestRecord {
392 digest_id: format!("sd-{}-{}", node_id, uuid::Uuid::new_v4()),
393 session_id,
394 node_id: node_id.to_string(),
395 source_path: source_path.to_string(),
396 artifact_kind: artifact_kind.to_string(),
397 hash: hash.to_vec(),
398 version,
399 };
400
401 self.store.record_structural_digest(&record)?;
402 log::debug!(
403 "Recorded structural digest for {} at {}",
404 node_id,
405 source_path
406 );
407 Ok(())
408 }
409
410 pub fn record_context_provenance(
412 &self,
413 provenance: &perspt_core::types::ContextProvenance,
414 ) -> Result<()> {
415 let session_id = self
416 .current_session
417 .as_ref()
418 .map(|s| s.session_id.clone())
419 .context("No active session to record context provenance")?;
420
421 let to_hex_32 =
422 |bytes: &[u8; 32]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
423 let to_hex_vec =
424 |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
425 let structural_hashes: Vec<String> = provenance
426 .structural_digest_hashes
427 .iter()
428 .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
429 .collect();
430 let summary_hashes: Vec<String> = provenance
431 .summary_digest_hashes
432 .iter()
433 .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
434 .collect();
435 let dep_hashes: Vec<String> = provenance
436 .dependency_commit_hashes
437 .iter()
438 .map(|(id, hash)| format!("{}:{}", id, to_hex_vec(hash)))
439 .collect();
440
441 let record = perspt_store::ContextProvenanceRecord {
442 session_id,
443 node_id: provenance.node_id.clone(),
444 context_package_id: provenance.context_package_id.clone(),
445 structural_hashes: serde_json::to_string(&structural_hashes).unwrap_or_default(),
446 summary_hashes: serde_json::to_string(&summary_hashes).unwrap_or_default(),
447 dependency_hashes: serde_json::to_string(&dep_hashes).unwrap_or_default(),
448 included_file_count: provenance.included_file_count as i32,
449 total_bytes: provenance.total_bytes as i32,
450 };
451
452 self.store.record_context_provenance(&record)?;
453 log::debug!(
454 "Recorded context provenance for node '{}' (package '{}')",
455 provenance.node_id,
456 provenance.context_package_id
457 );
458 Ok(())
459 }
460
461 pub fn get_context_provenance(
463 &self,
464 node_id: &str,
465 ) -> Result<Option<perspt_store::ContextProvenanceRecord>> {
466 let session_id = self
467 .current_session
468 .as_ref()
469 .map(|s| s.session_id.clone())
470 .context("No active session to query context provenance")?;
471
472 self.store.get_context_provenance(&session_id, node_id)
473 }
474
475 pub fn record_escalation_report(
481 &self,
482 report: &perspt_core::types::EscalationReport,
483 ) -> Result<()> {
484 let session_id = self
485 .current_session
486 .as_ref()
487 .map(|s| s.session_id.clone())
488 .context("No active session to record escalation report")?;
489
490 let record = perspt_store::EscalationReportRecord {
491 session_id,
492 node_id: report.node_id.clone(),
493 category: report.category.to_string(),
494 action: serde_json::to_string(&report.action).unwrap_or_default(),
495 energy_snapshot: serde_json::to_string(&report.energy_snapshot).unwrap_or_default(),
496 stage_outcomes: serde_json::to_string(&report.stage_outcomes).unwrap_or_default(),
497 evidence: report.evidence.clone(),
498 affected_node_ids: serde_json::to_string(&report.affected_node_ids).unwrap_or_default(),
499 };
500
501 self.store.record_escalation_report(&record)?;
502 log::debug!(
503 "Recorded escalation report for node '{}': {} → {}",
504 report.node_id,
505 report.category,
506 report.action
507 );
508 Ok(())
509 }
510
511 pub fn record_rewrite(&self, record: &perspt_core::types::RewriteRecord) -> Result<()> {
513 let session_id = self
514 .current_session
515 .as_ref()
516 .map(|s| s.session_id.clone())
517 .context("No active session to record rewrite")?;
518
519 let row = perspt_store::RewriteRecordRow {
520 session_id,
521 node_id: record.node_id.clone(),
522 action: serde_json::to_string(&record.action).unwrap_or_default(),
523 category: record.category.to_string(),
524 requeued_nodes: serde_json::to_string(&record.requeued_nodes).unwrap_or_default(),
525 inserted_nodes: serde_json::to_string(&record.inserted_nodes).unwrap_or_default(),
526 };
527
528 self.store.record_rewrite(&row)?;
529 log::debug!(
530 "Recorded rewrite for node '{}': {} ({} requeued, {} inserted)",
531 record.node_id,
532 record.action,
533 record.requeued_nodes.len(),
534 record.inserted_nodes.len()
535 );
536 Ok(())
537 }
538
539 pub fn get_rewrite_count_for_lineage(&self, lineage_base: &str) -> Result<usize> {
545 let session_id = self
546 .current_session
547 .as_ref()
548 .map(|s| s.session_id.clone())
549 .context("No active session to query rewrite count")?;
550
551 let records = self.store.get_rewrite_records(&session_id)?;
552 let count = records
553 .iter()
554 .filter(|r| r.node_id.starts_with(lineage_base))
555 .count();
556 Ok(count)
557 }
558
559 pub fn record_sheaf_validation(
561 &self,
562 node_id: &str,
563 result: &perspt_core::types::SheafValidationResult,
564 ) -> Result<()> {
565 let session_id = self
566 .current_session
567 .as_ref()
568 .map(|s| s.session_id.clone())
569 .context("No active session to record sheaf validation")?;
570
571 let row = perspt_store::SheafValidationRow {
572 session_id,
573 node_id: node_id.to_string(),
574 validator_class: result.validator_class.to_string(),
575 plugin_source: result.plugin_source.clone(),
576 passed: result.passed,
577 evidence_summary: result.evidence_summary.clone(),
578 affected_files: serde_json::to_string(&result.affected_files).unwrap_or_default(),
579 v_sheaf_contribution: result.v_sheaf_contribution,
580 requeue_targets: serde_json::to_string(&result.requeue_targets).unwrap_or_default(),
581 };
582
583 self.store.record_sheaf_validation(&row)?;
584 log::debug!(
585 "Recorded sheaf validation for node '{}': {} → {}",
586 node_id,
587 result.validator_class,
588 if result.passed { "pass" } else { "fail" }
589 );
590 Ok(())
591 }
592
593 pub fn get_escalation_reports(&self) -> Result<Vec<perspt_store::EscalationReportRecord>> {
595 let session_id = self
596 .current_session
597 .as_ref()
598 .map(|s| s.session_id.clone())
599 .context("No active session to query escalation reports")?;
600
601 self.store.get_escalation_reports(&session_id)
602 }
603
604 pub fn record_verification_result(
610 &self,
611 node_id: &str,
612 result: &perspt_core::types::VerificationResult,
613 ) -> Result<()> {
614 let session_id = self.session_id()?;
615
616 let result_json = serde_json::to_string(result).unwrap_or_default();
617 let row = perspt_store::VerificationResultRow {
618 session_id,
619 node_id: node_id.to_string(),
620 result_json,
621 syntax_ok: result.syntax_ok,
622 build_ok: result.build_ok,
623 tests_ok: result.tests_ok,
624 lint_ok: result.lint_ok,
625 diagnostics_count: result.diagnostics_count as i32,
626 tests_passed: result.tests_passed as i32,
627 tests_failed: result.tests_failed as i32,
628 degraded: result.degraded,
629 degraded_reason: result.degraded_reason.clone(),
630 };
631
632 self.store.record_verification_result(&row)?;
633 log::debug!(
634 "Recorded verification result for node '{}': syn={} build={} test={} degraded={}",
635 node_id,
636 result.syntax_ok,
637 result.build_ok,
638 result.tests_ok,
639 result.degraded
640 );
641 Ok(())
642 }
643
644 pub fn get_verification_result(
646 &self,
647 node_id: &str,
648 ) -> Result<Option<perspt_store::VerificationResultRow>> {
649 let session_id = self.session_id()?;
650 self.store.get_verification_result(&session_id, node_id)
651 }
652
653 pub fn record_artifact_bundle(
655 &self,
656 node_id: &str,
657 bundle: &perspt_core::types::ArtifactBundle,
658 ) -> Result<()> {
659 let session_id = self.session_id()?;
660
661 let bundle_json = serde_json::to_string(bundle).unwrap_or_default();
662 let touched_files: Vec<String> = bundle
663 .artifacts
664 .iter()
665 .map(|a| a.path().to_string())
666 .collect();
667
668 let row = perspt_store::ArtifactBundleRow {
669 session_id,
670 node_id: node_id.to_string(),
671 bundle_json,
672 artifact_count: bundle.artifacts.len() as i32,
673 command_count: bundle.commands.len() as i32,
674 touched_files: serde_json::to_string(&touched_files).unwrap_or_default(),
675 };
676
677 self.store.record_artifact_bundle(&row)?;
678 log::debug!(
679 "Recorded artifact bundle for node '{}': {} artifacts, {} commands",
680 node_id,
681 bundle.artifacts.len(),
682 bundle.commands.len()
683 );
684 Ok(())
685 }
686
687 pub fn get_artifact_bundle(
689 &self,
690 node_id: &str,
691 ) -> Result<Option<perspt_store::ArtifactBundleRow>> {
692 let session_id = self.session_id()?;
693 self.store.get_artifact_bundle(&session_id, node_id)
694 }
695
696 pub fn record_task_graph_edge(
702 &self,
703 parent_node_id: &str,
704 child_node_id: &str,
705 edge_type: &str,
706 ) -> Result<()> {
707 let session_id = self.session_id()?;
708 let row = perspt_store::TaskGraphEdgeRow {
709 session_id,
710 parent_node_id: parent_node_id.to_string(),
711 child_node_id: child_node_id.to_string(),
712 edge_type: edge_type.to_string(),
713 };
714 self.store.record_task_graph_edge(&row)?;
715 log::debug!(
716 "Recorded task graph edge: {} → {} ({})",
717 parent_node_id,
718 child_node_id,
719 edge_type
720 );
721 Ok(())
722 }
723
724 pub fn get_task_graph_edges(&self) -> Result<Vec<perspt_store::TaskGraphEdgeRow>> {
726 let session_id = self.session_id()?;
727 self.store.get_task_graph_edges(&session_id)
728 }
729
730 pub fn get_sheaf_validations(
732 &self,
733 node_id: &str,
734 ) -> Result<Vec<perspt_store::SheafValidationRow>> {
735 let session_id = self.session_id()?;
736 self.store.get_sheaf_validations(&session_id, node_id)
737 }
738
739 pub fn load_session_snapshot(&self) -> Result<SessionSnapshot> {
746 let session_id = self.session_id()?;
747
748 let node_states = self
749 .store
750 .get_latest_node_states(&session_id)
751 .unwrap_or_default();
752
753 let graph_edges = self
754 .store
755 .get_task_graph_edges(&session_id)
756 .unwrap_or_default();
757
758 let branches = self
759 .store
760 .get_provisional_branches(&session_id)
761 .unwrap_or_default();
762
763 let escalation_reports = self
764 .store
765 .get_escalation_reports(&session_id)
766 .unwrap_or_default();
767
768 let flushes = self
769 .store
770 .get_branch_flushes(&session_id)
771 .unwrap_or_default();
772
773 let mut node_details: Vec<NodeSnapshotDetail> = Vec::with_capacity(node_states.len());
775 for ns in &node_states {
776 let nid = &ns.node_id;
777
778 let energy_history = self
779 .store
780 .get_energy_history(&session_id, nid)
781 .unwrap_or_default();
782
783 let verification = self
784 .store
785 .get_verification_result(&session_id, nid)
786 .ok()
787 .flatten();
788
789 let artifact_bundle = self
790 .store
791 .get_artifact_bundle(&session_id, nid)
792 .ok()
793 .flatten();
794
795 let sheaf_validations = self
796 .store
797 .get_sheaf_validations(&session_id, nid)
798 .unwrap_or_default();
799
800 let interface_seals = self
801 .store
802 .get_interface_seals(&session_id, nid)
803 .unwrap_or_default();
804
805 let context_provenance = self
806 .store
807 .get_context_provenance(&session_id, nid)
808 .ok()
809 .flatten();
810
811 node_details.push(NodeSnapshotDetail {
812 record: ns.clone(),
813 energy_history,
814 verification,
815 artifact_bundle,
816 sheaf_validations,
817 interface_seals,
818 context_provenance,
819 });
820 }
821
822 log::info!(
823 "Loaded session snapshot: {} nodes, {} edges, {} branches",
824 node_details.len(),
825 graph_edges.len(),
826 branches.len()
827 );
828
829 Ok(SessionSnapshot {
830 session_id,
831 node_details,
832 graph_edges,
833 branches,
834 escalation_reports,
835 flushes,
836 })
837 }
838
839 fn session_id(&self) -> Result<String> {
845 self.current_session
846 .as_ref()
847 .map(|s| s.session_id.clone())
848 .context("No active session")
849 }
850
851 pub fn record_provisional_branch(
853 &self,
854 branch: &perspt_core::types::ProvisionalBranch,
855 ) -> Result<()> {
856 let row = perspt_store::ProvisionalBranchRow {
857 branch_id: branch.branch_id.clone(),
858 session_id: branch.session_id.clone(),
859 node_id: branch.node_id.clone(),
860 parent_node_id: branch.parent_node_id.clone(),
861 state: branch.state.to_string(),
862 parent_seal_hash: branch.parent_seal_hash.map(|h| h.to_vec()),
863 sandbox_dir: branch.sandbox_dir.clone(),
864 };
865
866 self.store.record_provisional_branch(&row)?;
867 log::debug!(
868 "Recorded provisional branch '{}' for node '{}' (parent: '{}')",
869 branch.branch_id,
870 branch.node_id,
871 branch.parent_node_id
872 );
873 Ok(())
874 }
875
876 pub fn update_branch_state(&self, branch_id: &str, new_state: &str) -> Result<()> {
878 self.store.update_branch_state(branch_id, new_state)?;
879 log::debug!("Updated branch '{}' state to '{}'", branch_id, new_state);
880 Ok(())
881 }
882
883 pub fn get_provisional_branches(&self) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
885 let session_id = self.session_id()?;
886 self.store.get_provisional_branches(&session_id)
887 }
888
889 pub fn get_live_branches_for_parent(
891 &self,
892 parent_node_id: &str,
893 ) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
894 let session_id = self.session_id()?;
895 self.store
896 .get_live_branches_for_parent(&session_id, parent_node_id)
897 }
898
899 pub fn flush_branches_for_parent(&self, parent_node_id: &str) -> Result<Vec<String>> {
901 let session_id = self.session_id()?;
902 self.store
903 .flush_branches_for_parent(&session_id, parent_node_id)
904 }
905
906 pub fn record_branch_lineage(&self, lineage: &perspt_core::types::BranchLineage) -> Result<()> {
908 let row = perspt_store::BranchLineageRow {
909 lineage_id: lineage.lineage_id.clone(),
910 parent_branch_id: lineage.parent_branch_id.clone(),
911 child_branch_id: lineage.child_branch_id.clone(),
912 depends_on_seal: lineage.depends_on_seal,
913 };
914
915 self.store.record_branch_lineage(&row)?;
916 log::debug!(
917 "Recorded branch lineage: {} → {}",
918 lineage.parent_branch_id,
919 lineage.child_branch_id
920 );
921 Ok(())
922 }
923
924 pub fn record_interface_seal(
926 &self,
927 seal: &perspt_core::types::InterfaceSealRecord,
928 ) -> Result<()> {
929 let row = perspt_store::InterfaceSealRow {
930 seal_id: seal.seal_id.clone(),
931 session_id: seal.session_id.clone(),
932 node_id: seal.node_id.clone(),
933 sealed_path: seal.sealed_path.clone(),
934 artifact_kind: seal.artifact_kind.to_string(),
935 seal_hash: seal.seal_hash.to_vec(),
936 version: seal.version as i32,
937 };
938
939 self.store.record_interface_seal(&row)?;
940 log::debug!(
941 "Recorded interface seal '{}' for node '{}' at '{}'",
942 seal.seal_id,
943 seal.node_id,
944 seal.sealed_path
945 );
946 Ok(())
947 }
948
949 pub fn get_interface_seals(
951 &self,
952 node_id: &str,
953 ) -> Result<Vec<perspt_store::InterfaceSealRow>> {
954 let session_id = self.session_id()?;
955 self.store.get_interface_seals(&session_id, node_id)
956 }
957
958 pub fn record_branch_flush(&self, flush: &perspt_core::types::BranchFlushRecord) -> Result<()> {
960 let row = perspt_store::BranchFlushRow {
961 flush_id: flush.flush_id.clone(),
962 session_id: flush.session_id.clone(),
963 parent_node_id: flush.parent_node_id.clone(),
964 flushed_branch_ids: serde_json::to_string(&flush.flushed_branch_ids)
965 .unwrap_or_default(),
966 requeue_node_ids: serde_json::to_string(&flush.requeue_node_ids).unwrap_or_default(),
967 reason: flush.reason.clone(),
968 };
969
970 self.store.record_branch_flush(&row)?;
971 log::debug!(
972 "Recorded branch flush for parent '{}': {} branches flushed",
973 flush.parent_node_id,
974 flush.flushed_branch_ids.len()
975 );
976 Ok(())
977 }
978
979 pub fn get_branch_flushes(&self) -> Result<Vec<perspt_store::BranchFlushRow>> {
981 let session_id = self.session_id()?;
982 self.store.get_branch_flushes(&session_id)
983 }
984
985 pub fn record_review_outcome(
991 &self,
992 node_id: &str,
993 outcome: &str,
994 reviewer_note: Option<&str>,
995 energy_at_review: Option<f64>,
996 degraded: Option<bool>,
997 escalation_category: Option<&str>,
998 ) -> Result<()> {
999 let session_id = self.session_id()?;
1000 let row = perspt_store::ReviewOutcomeRow {
1001 session_id,
1002 node_id: node_id.to_string(),
1003 outcome: outcome.to_string(),
1004 reviewer_note: reviewer_note.map(|s| s.to_string()),
1005 energy_at_review,
1006 degraded,
1007 escalation_category: escalation_category.map(|s| s.to_string()),
1008 };
1009 self.store.record_review_outcome(&row)
1010 }
1011
1012 pub fn get_review_outcomes(
1014 &self,
1015 node_id: &str,
1016 ) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1017 let session_id = self.session_id()?;
1018 self.store.get_review_outcomes(&session_id, node_id)
1019 }
1020
1021 pub fn get_all_review_outcomes(&self) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1023 let session_id = self.session_id()?;
1024 self.store.get_all_review_outcomes(&session_id)
1025 }
1026
1027 pub fn node_review_summary(&self, node_id: &str) -> Result<NodeReviewSummary> {
1037 let session_id = self.session_id()?;
1038
1039 let energy_history = self
1040 .store
1041 .get_energy_history(&session_id, node_id)
1042 .unwrap_or_default();
1043
1044 let latest_energy = energy_history.last().cloned();
1045
1046 let escalation_reports = self
1047 .store
1048 .get_escalation_reports(&session_id)
1049 .unwrap_or_default()
1050 .into_iter()
1051 .filter(|r| r.node_id == node_id)
1052 .collect::<Vec<_>>();
1053
1054 let sheaf_validations = self
1055 .store
1056 .get_sheaf_validations(&session_id, node_id)
1057 .unwrap_or_default();
1058
1059 let interface_seals = self
1060 .store
1061 .get_interface_seals(&session_id, node_id)
1062 .unwrap_or_default();
1063
1064 let context_provenance = self
1065 .store
1066 .get_context_provenance(&session_id, node_id)
1067 .ok()
1068 .flatten()
1069 .into_iter()
1070 .collect::<Vec<_>>();
1071
1072 let branches: Vec<_> = self
1073 .store
1074 .get_provisional_branches(&session_id)
1075 .unwrap_or_default()
1076 .into_iter()
1077 .filter(|b| b.node_id == node_id)
1078 .collect();
1079
1080 let attempt_count = energy_history.len().max(1) as u32;
1081
1082 Ok(NodeReviewSummary {
1083 node_id: node_id.to_string(),
1084 latest_energy,
1085 energy_history,
1086 attempt_count,
1087 escalation_reports,
1088 sheaf_validations,
1089 interface_seals,
1090 context_provenance,
1091 branches,
1092 })
1093 }
1094
1095 pub fn session_summary(&self) -> Result<SessionReviewSummary> {
1098 let session_id = self.session_id()?;
1099
1100 let node_states = self.store.get_node_states(&session_id).unwrap_or_default();
1101 let total_nodes = node_states.len();
1102 let completed = node_states
1103 .iter()
1104 .filter(|n| n.state == "COMPLETED" || n.state == "STABLE")
1105 .count();
1106 let failed = node_states.iter().filter(|n| n.state == "FAILED").count();
1107 let escalated = node_states
1108 .iter()
1109 .filter(|n| n.state == "Escalated")
1110 .count();
1111
1112 let mut total_energy: f32 = 0.0;
1114 let mut node_energies: Vec<(String, perspt_store::EnergyRecord)> = Vec::new();
1115 for ns in &node_states {
1116 if let Ok(history) = self.store.get_energy_history(&session_id, &ns.node_id) {
1117 if let Some(latest) = history.last() {
1118 total_energy += latest.v_total;
1119 node_energies.push((ns.node_id.clone(), latest.clone()));
1120 }
1121 }
1122 }
1123
1124 let escalation_reports = self
1125 .store
1126 .get_escalation_reports(&session_id)
1127 .unwrap_or_default();
1128
1129 let branches = self
1130 .store
1131 .get_provisional_branches(&session_id)
1132 .unwrap_or_default();
1133
1134 let active_branches = branches.iter().filter(|b| b.state == "active").count();
1135 let sealed_branches = branches.iter().filter(|b| b.state == "sealed").count();
1136 let merged_branches = branches.iter().filter(|b| b.state == "merged").count();
1137 let flushed_branches = branches.iter().filter(|b| b.state == "flushed").count();
1138
1139 let flushes = self
1140 .store
1141 .get_branch_flushes(&session_id)
1142 .unwrap_or_default();
1143
1144 let review_outcomes = self
1146 .store
1147 .get_all_review_outcomes(&session_id)
1148 .unwrap_or_default();
1149 let review_total = review_outcomes.len();
1150 let reviews_approved = review_outcomes
1151 .iter()
1152 .filter(|r| r.outcome.starts_with("approved") || r.outcome == "auto_approved")
1153 .count();
1154 let reviews_rejected = review_outcomes
1155 .iter()
1156 .filter(|r| r.outcome == "rejected" || r.outcome == "aborted")
1157 .count();
1158 let reviews_corrected = review_outcomes
1159 .iter()
1160 .filter(|r| r.outcome == "correction_requested")
1161 .count();
1162
1163 Ok(SessionReviewSummary {
1164 session_id,
1165 total_nodes,
1166 completed,
1167 failed,
1168 escalated,
1169 total_energy,
1170 node_energies,
1171 escalation_reports,
1172 branches_total: branches.len(),
1173 active_branches,
1174 sealed_branches,
1175 merged_branches,
1176 flushed_branches,
1177 flush_decisions: flushes,
1178 review_total,
1179 reviews_approved,
1180 reviews_rejected,
1181 reviews_corrected,
1182 })
1183 }
1184}
1185
1186impl MerkleLedger {
1191 pub fn record_feature_charter(&self, charter: &perspt_core::FeatureCharter) -> Result<()> {
1193 let session_id = self.session_id()?;
1194 let row = perspt_store::FeatureCharterRow {
1195 charter_id: charter.charter_id.clone(),
1196 session_id,
1197 scope_description: charter.scope_description.clone(),
1198 max_modules: charter.max_modules.map(|v| v as i32),
1199 max_files: charter.max_files.map(|v| v as i32),
1200 max_revisions: charter.max_revisions.map(|v| v as i32),
1201 language_constraint: charter.language_constraint.clone(),
1202 };
1203 self.store.record_feature_charter(&row)?;
1204 log::debug!("Recorded feature charter '{}'", charter.charter_id);
1205 Ok(())
1206 }
1207
1208 pub fn get_feature_charter(&self) -> Result<Option<perspt_store::FeatureCharterRow>> {
1210 let session_id = self.session_id()?;
1211 self.store.get_feature_charter(&session_id)
1212 }
1213
1214 pub fn record_plan_revision(&self, revision: &perspt_core::PlanRevision) -> Result<()> {
1216 let session_id = self.session_id()?;
1217 let plan_json = serde_json::to_string(&revision.plan).unwrap_or_default();
1218 let row = perspt_store::PlanRevisionRow {
1219 revision_id: revision.revision_id.clone(),
1220 session_id,
1221 sequence: revision.sequence as i32,
1222 plan_json,
1223 reason: revision.reason.clone(),
1224 supersedes: revision.supersedes.clone(),
1225 status: revision.status.to_string(),
1226 };
1227 self.store.record_plan_revision(&row)?;
1228 log::debug!(
1229 "Recorded plan revision '{}' (seq={}, status={})",
1230 revision.revision_id,
1231 revision.sequence,
1232 revision.status
1233 );
1234 Ok(())
1235 }
1236
1237 pub fn get_active_plan_revision(&self) -> Result<Option<perspt_store::PlanRevisionRow>> {
1239 let session_id = self.session_id()?;
1240 self.store.get_active_plan_revision(&session_id)
1241 }
1242
1243 pub fn get_plan_revisions(&self) -> Result<Vec<perspt_store::PlanRevisionRow>> {
1245 let session_id = self.session_id()?;
1246 self.store.get_plan_revisions(&session_id)
1247 }
1248
1249 pub fn supersede_plan_revision(&self, revision_id: &str) -> Result<()> {
1251 self.store.supersede_plan_revision(revision_id)?;
1252 log::debug!("Superseded plan revision '{}'", revision_id);
1253 Ok(())
1254 }
1255
1256 pub fn record_repair_footprint(&self, footprint: &perspt_core::RepairFootprint) -> Result<()> {
1258 let session_id = self.session_id()?;
1259 let row = perspt_store::RepairFootprintRow {
1260 footprint_id: footprint.footprint_id.clone(),
1261 session_id,
1262 node_id: footprint.node_id.clone(),
1263 revision_id: footprint.revision_id.clone(),
1264 attempt: footprint.attempt as i32,
1265 affected_files: serde_json::to_string(&footprint.affected_files).unwrap_or_default(),
1266 bundle_json: serde_json::to_string(&footprint.applied_bundle).unwrap_or_default(),
1267 diagnosis: footprint.diagnosis.clone(),
1268 resolved: footprint.resolved,
1269 };
1270 self.store.record_repair_footprint(&row)?;
1271 log::debug!(
1272 "Recorded repair footprint '{}' for node '{}' (attempt {})",
1273 footprint.footprint_id,
1274 footprint.node_id,
1275 footprint.attempt
1276 );
1277 Ok(())
1278 }
1279
1280 pub fn get_repair_footprints(
1282 &self,
1283 node_id: &str,
1284 ) -> Result<Vec<perspt_store::RepairFootprintRow>> {
1285 let session_id = self.session_id()?;
1286 self.store.get_repair_footprints(&session_id, node_id)
1287 }
1288
1289 pub fn upsert_budget_envelope(&self, budget: &perspt_core::BudgetEnvelope) -> Result<()> {
1291 let session_id = self.session_id()?;
1292 let row = perspt_store::BudgetEnvelopeRow {
1293 session_id,
1294 max_steps: budget.max_steps.map(|v| v as i32),
1295 steps_used: budget.steps_used as i32,
1296 max_revisions: budget.max_revisions.map(|v| v as i32),
1297 revisions_used: budget.revisions_used as i32,
1298 max_cost_usd: budget.max_cost_usd,
1299 cost_used_usd: budget.cost_used_usd,
1300 };
1301 self.store.upsert_budget_envelope(&row)?;
1302 log::debug!("Upserted budget envelope for session");
1303 Ok(())
1304 }
1305
1306 pub fn get_budget_envelope(&self) -> Result<Option<perspt_store::BudgetEnvelopeRow>> {
1308 let session_id = self.session_id()?;
1309 self.store.get_budget_envelope(&session_id)
1310 }
1311
1312 pub fn record_step(&self, record: &perspt_store::SrbnStepRecord) -> Result<()> {
1318 self.store.record_step(record)
1319 }
1320
1321 pub fn get_step_timeline(&self, node_id: &str) -> Result<Vec<perspt_store::SrbnStepRecord>> {
1323 let session_id = self.session_id()?;
1324 self.store.get_step_timeline(&session_id, node_id)
1325 }
1326
1327 pub fn get_session_steps(&self) -> Result<Vec<perspt_store::SrbnStepRecord>> {
1329 let session_id = self.session_id()?;
1330 self.store.get_session_steps(&session_id)
1331 }
1332
1333 pub fn record_correction_attempt(
1335 &self,
1336 record: &perspt_store::CorrectionAttemptRow,
1337 ) -> Result<()> {
1338 self.store.record_correction_attempt(record)
1339 }
1340
1341 pub fn get_correction_attempts(
1343 &self,
1344 node_id: &str,
1345 ) -> Result<Vec<perspt_store::CorrectionAttemptRow>> {
1346 let session_id = self.session_id()?;
1347 self.store.get_correction_attempts(&session_id, node_id)
1348 }
1349}
1350
1351#[derive(Debug, Clone)]
1355pub struct NodeReviewSummary {
1356 pub node_id: String,
1357 pub latest_energy: Option<perspt_store::EnergyRecord>,
1358 pub energy_history: Vec<perspt_store::EnergyRecord>,
1359 pub attempt_count: u32,
1360 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1361 pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1362 pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1363 pub context_provenance: Vec<perspt_store::ContextProvenanceRecord>,
1364 pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1365}
1366
1367#[derive(Debug, Clone)]
1371pub struct SessionReviewSummary {
1372 pub session_id: String,
1373 pub total_nodes: usize,
1374 pub completed: usize,
1375 pub failed: usize,
1376 pub escalated: usize,
1377 pub total_energy: f32,
1378 pub node_energies: Vec<(String, perspt_store::EnergyRecord)>,
1379 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1380 pub branches_total: usize,
1381 pub active_branches: usize,
1382 pub sealed_branches: usize,
1383 pub merged_branches: usize,
1384 pub flushed_branches: usize,
1385 pub flush_decisions: Vec<perspt_store::BranchFlushRow>,
1386 pub review_total: usize,
1388 pub reviews_approved: usize,
1389 pub reviews_rejected: usize,
1390 pub reviews_corrected: usize,
1391}
1392
1393#[derive(Debug, Clone)]
1395pub struct LedgerStats {
1396 pub total_sessions: usize,
1397 pub total_commits: usize,
1398 pub db_size_bytes: u64,
1399}
1400
1401#[derive(Debug, Clone)]
1403pub struct NodeSnapshotDetail {
1404 pub record: NodeStateRecord,
1405 pub energy_history: Vec<perspt_store::EnergyRecord>,
1406 pub verification: Option<perspt_store::VerificationResultRow>,
1407 pub artifact_bundle: Option<perspt_store::ArtifactBundleRow>,
1408 pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1409 pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1410 pub context_provenance: Option<perspt_store::ContextProvenanceRecord>,
1411}
1412
1413#[derive(Debug, Clone)]
1419pub struct SessionSnapshot {
1420 pub session_id: String,
1421 pub node_details: Vec<NodeSnapshotDetail>,
1422 pub graph_edges: Vec<perspt_store::TaskGraphEdgeRow>,
1423 pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1424 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1425 pub flushes: Vec<perspt_store::BranchFlushRow>,
1426}
1427
1428fn generate_commit_id() -> String {
1430 use std::time::{SystemTime, UNIX_EPOCH};
1431 let now = SystemTime::now()
1432 .duration_since(UNIX_EPOCH)
1433 .unwrap()
1434 .as_nanos();
1435 format!("{:x}", now)
1436}
1437
1438fn chrono_timestamp() -> i64 {
1440 use std::time::{SystemTime, UNIX_EPOCH};
1441 SystemTime::now()
1442 .duration_since(UNIX_EPOCH)
1443 .unwrap()
1444 .as_secs() as i64
1445}
1446
1447fn chrono_iso_now() -> String {
1449 use std::time::{SystemTime, UNIX_EPOCH};
1450 let secs = SystemTime::now()
1451 .duration_since(UNIX_EPOCH)
1452 .unwrap()
1453 .as_secs();
1454 let days = secs / 86400;
1456 let time = secs % 86400;
1457 let h = time / 3600;
1458 let m = (time % 3600) / 60;
1459 let s = time % 60;
1460 let (y, mo, d) = days_to_ymd(days);
1462 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s)
1463}
1464
1465fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1467 let z = days + 719468;
1469 let era = z / 146097;
1470 let doe = z - era * 146097;
1471 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1472 let y = yoe + era * 400;
1473 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1474 let mp = (5 * doy + 2) / 153;
1475 let d = doy - (153 * mp + 2) / 5 + 1;
1476 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1477 let y = if m <= 2 { y + 1 } else { y };
1478 (y, m, d)
1479}