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 store(&self) -> &SessionStore {
328 &self.store
329 }
330
331 pub fn record_structural_digest(
337 &self,
338 node_id: &str,
339 source_path: &str,
340 artifact_kind: &str,
341 hash: &[u8],
342 version: i32,
343 ) -> Result<()> {
344 let session_id = self
345 .current_session
346 .as_ref()
347 .map(|s| s.session_id.clone())
348 .context("No active session to record structural digest")?;
349
350 let record = perspt_store::StructuralDigestRecord {
351 digest_id: format!("sd-{}-{}", node_id, uuid::Uuid::new_v4()),
352 session_id,
353 node_id: node_id.to_string(),
354 source_path: source_path.to_string(),
355 artifact_kind: artifact_kind.to_string(),
356 hash: hash.to_vec(),
357 version,
358 };
359
360 self.store.record_structural_digest(&record)?;
361 log::debug!(
362 "Recorded structural digest for {} at {}",
363 node_id,
364 source_path
365 );
366 Ok(())
367 }
368
369 pub fn get_structural_digests(
371 &self,
372 node_id: &str,
373 ) -> Result<Vec<perspt_store::StructuralDigestRecord>> {
374 let session_id = self
375 .current_session
376 .as_ref()
377 .map(|s| s.session_id.clone())
378 .context("No active session to query structural digests")?;
379
380 self.store.get_structural_digests(&session_id, node_id)
381 }
382
383 pub fn record_context_provenance(
385 &self,
386 provenance: &perspt_core::types::ContextProvenance,
387 ) -> Result<()> {
388 let session_id = self
389 .current_session
390 .as_ref()
391 .map(|s| s.session_id.clone())
392 .context("No active session to record context provenance")?;
393
394 let to_hex_32 =
395 |bytes: &[u8; 32]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
396 let to_hex_vec =
397 |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() };
398 let structural_hashes: Vec<String> = provenance
399 .structural_digest_hashes
400 .iter()
401 .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
402 .collect();
403 let summary_hashes: Vec<String> = provenance
404 .summary_digest_hashes
405 .iter()
406 .map(|(id, hash)| format!("{}:{}", id, to_hex_32(hash)))
407 .collect();
408 let dep_hashes: Vec<String> = provenance
409 .dependency_commit_hashes
410 .iter()
411 .map(|(id, hash)| format!("{}:{}", id, to_hex_vec(hash)))
412 .collect();
413
414 let record = perspt_store::ContextProvenanceRecord {
415 session_id,
416 node_id: provenance.node_id.clone(),
417 context_package_id: provenance.context_package_id.clone(),
418 structural_hashes: serde_json::to_string(&structural_hashes).unwrap_or_default(),
419 summary_hashes: serde_json::to_string(&summary_hashes).unwrap_or_default(),
420 dependency_hashes: serde_json::to_string(&dep_hashes).unwrap_or_default(),
421 included_file_count: provenance.included_file_count as i32,
422 total_bytes: provenance.total_bytes as i32,
423 };
424
425 self.store.record_context_provenance(&record)?;
426 log::debug!(
427 "Recorded context provenance for node '{}' (package '{}')",
428 provenance.node_id,
429 provenance.context_package_id
430 );
431 Ok(())
432 }
433
434 pub fn get_context_provenance(
436 &self,
437 node_id: &str,
438 ) -> Result<Option<perspt_store::ContextProvenanceRecord>> {
439 let session_id = self
440 .current_session
441 .as_ref()
442 .map(|s| s.session_id.clone())
443 .context("No active session to query context provenance")?;
444
445 self.store.get_context_provenance(&session_id, node_id)
446 }
447
448 pub fn record_escalation_report(
454 &self,
455 report: &perspt_core::types::EscalationReport,
456 ) -> Result<()> {
457 let session_id = self
458 .current_session
459 .as_ref()
460 .map(|s| s.session_id.clone())
461 .context("No active session to record escalation report")?;
462
463 let record = perspt_store::EscalationReportRecord {
464 session_id,
465 node_id: report.node_id.clone(),
466 category: report.category.to_string(),
467 action: serde_json::to_string(&report.action).unwrap_or_default(),
468 energy_snapshot: serde_json::to_string(&report.energy_snapshot).unwrap_or_default(),
469 stage_outcomes: serde_json::to_string(&report.stage_outcomes).unwrap_or_default(),
470 evidence: report.evidence.clone(),
471 affected_node_ids: serde_json::to_string(&report.affected_node_ids).unwrap_or_default(),
472 };
473
474 self.store.record_escalation_report(&record)?;
475 log::debug!(
476 "Recorded escalation report for node '{}': {} → {}",
477 report.node_id,
478 report.category,
479 report.action
480 );
481 Ok(())
482 }
483
484 pub fn record_rewrite(&self, record: &perspt_core::types::RewriteRecord) -> Result<()> {
486 let session_id = self
487 .current_session
488 .as_ref()
489 .map(|s| s.session_id.clone())
490 .context("No active session to record rewrite")?;
491
492 let row = perspt_store::RewriteRecordRow {
493 session_id,
494 node_id: record.node_id.clone(),
495 action: serde_json::to_string(&record.action).unwrap_or_default(),
496 category: record.category.to_string(),
497 requeued_nodes: serde_json::to_string(&record.requeued_nodes).unwrap_or_default(),
498 inserted_nodes: serde_json::to_string(&record.inserted_nodes).unwrap_or_default(),
499 };
500
501 self.store.record_rewrite(&row)?;
502 log::debug!(
503 "Recorded rewrite for node '{}': {} ({} requeued, {} inserted)",
504 record.node_id,
505 record.action,
506 record.requeued_nodes.len(),
507 record.inserted_nodes.len()
508 );
509 Ok(())
510 }
511
512 pub fn get_rewrite_count_for_lineage(&self, lineage_base: &str) -> Result<usize> {
518 let session_id = self
519 .current_session
520 .as_ref()
521 .map(|s| s.session_id.clone())
522 .context("No active session to query rewrite count")?;
523
524 let records = self.store.get_rewrite_records(&session_id)?;
525 let count = records
526 .iter()
527 .filter(|r| r.node_id.starts_with(lineage_base))
528 .count();
529 Ok(count)
530 }
531
532 pub fn record_sheaf_validation(
534 &self,
535 node_id: &str,
536 result: &perspt_core::types::SheafValidationResult,
537 ) -> Result<()> {
538 let session_id = self
539 .current_session
540 .as_ref()
541 .map(|s| s.session_id.clone())
542 .context("No active session to record sheaf validation")?;
543
544 let row = perspt_store::SheafValidationRow {
545 session_id,
546 node_id: node_id.to_string(),
547 validator_class: result.validator_class.to_string(),
548 plugin_source: result.plugin_source.clone(),
549 passed: result.passed,
550 evidence_summary: result.evidence_summary.clone(),
551 affected_files: serde_json::to_string(&result.affected_files).unwrap_or_default(),
552 v_sheaf_contribution: result.v_sheaf_contribution,
553 requeue_targets: serde_json::to_string(&result.requeue_targets).unwrap_or_default(),
554 };
555
556 self.store.record_sheaf_validation(&row)?;
557 log::debug!(
558 "Recorded sheaf validation for node '{}': {} → {}",
559 node_id,
560 result.validator_class,
561 if result.passed { "pass" } else { "fail" }
562 );
563 Ok(())
564 }
565
566 pub fn get_escalation_reports(&self) -> Result<Vec<perspt_store::EscalationReportRecord>> {
568 let session_id = self
569 .current_session
570 .as_ref()
571 .map(|s| s.session_id.clone())
572 .context("No active session to query escalation reports")?;
573
574 self.store.get_escalation_reports(&session_id)
575 }
576
577 pub fn record_verification_result(
583 &self,
584 node_id: &str,
585 result: &perspt_core::types::VerificationResult,
586 ) -> Result<()> {
587 let session_id = self.session_id()?;
588
589 let result_json = serde_json::to_string(result).unwrap_or_default();
590 let row = perspt_store::VerificationResultRow {
591 session_id,
592 node_id: node_id.to_string(),
593 result_json,
594 syntax_ok: result.syntax_ok,
595 build_ok: result.build_ok,
596 tests_ok: result.tests_ok,
597 lint_ok: result.lint_ok,
598 diagnostics_count: result.diagnostics_count as i32,
599 tests_passed: result.tests_passed as i32,
600 tests_failed: result.tests_failed as i32,
601 degraded: result.degraded,
602 degraded_reason: result.degraded_reason.clone(),
603 };
604
605 self.store.record_verification_result(&row)?;
606 log::debug!(
607 "Recorded verification result for node '{}': syn={} build={} test={} degraded={}",
608 node_id,
609 result.syntax_ok,
610 result.build_ok,
611 result.tests_ok,
612 result.degraded
613 );
614 Ok(())
615 }
616
617 pub fn get_verification_result(
619 &self,
620 node_id: &str,
621 ) -> Result<Option<perspt_store::VerificationResultRow>> {
622 let session_id = self.session_id()?;
623 self.store.get_verification_result(&session_id, node_id)
624 }
625
626 pub fn record_artifact_bundle(
628 &self,
629 node_id: &str,
630 bundle: &perspt_core::types::ArtifactBundle,
631 ) -> Result<()> {
632 let session_id = self.session_id()?;
633
634 let bundle_json = serde_json::to_string(bundle).unwrap_or_default();
635 let touched_files: Vec<String> = bundle
636 .artifacts
637 .iter()
638 .map(|a| a.path().to_string())
639 .collect();
640
641 let row = perspt_store::ArtifactBundleRow {
642 session_id,
643 node_id: node_id.to_string(),
644 bundle_json,
645 artifact_count: bundle.artifacts.len() as i32,
646 command_count: bundle.commands.len() as i32,
647 touched_files: serde_json::to_string(&touched_files).unwrap_or_default(),
648 };
649
650 self.store.record_artifact_bundle(&row)?;
651 log::debug!(
652 "Recorded artifact bundle for node '{}': {} artifacts, {} commands",
653 node_id,
654 bundle.artifacts.len(),
655 bundle.commands.len()
656 );
657 Ok(())
658 }
659
660 pub fn get_artifact_bundle(
662 &self,
663 node_id: &str,
664 ) -> Result<Option<perspt_store::ArtifactBundleRow>> {
665 let session_id = self.session_id()?;
666 self.store.get_artifact_bundle(&session_id, node_id)
667 }
668
669 pub fn record_task_graph_edge(
675 &self,
676 parent_node_id: &str,
677 child_node_id: &str,
678 edge_type: &str,
679 ) -> Result<()> {
680 let session_id = self.session_id()?;
681 let row = perspt_store::TaskGraphEdgeRow {
682 session_id,
683 parent_node_id: parent_node_id.to_string(),
684 child_node_id: child_node_id.to_string(),
685 edge_type: edge_type.to_string(),
686 };
687 self.store.record_task_graph_edge(&row)?;
688 log::debug!(
689 "Recorded task graph edge: {} → {} ({})",
690 parent_node_id,
691 child_node_id,
692 edge_type
693 );
694 Ok(())
695 }
696
697 pub fn get_task_graph_edges(&self) -> Result<Vec<perspt_store::TaskGraphEdgeRow>> {
699 let session_id = self.session_id()?;
700 self.store.get_task_graph_edges(&session_id)
701 }
702
703 pub fn get_sheaf_validations(
705 &self,
706 node_id: &str,
707 ) -> Result<Vec<perspt_store::SheafValidationRow>> {
708 let session_id = self.session_id()?;
709 self.store.get_sheaf_validations(&session_id, node_id)
710 }
711
712 pub fn load_session_snapshot(&self) -> Result<SessionSnapshot> {
719 let session_id = self.session_id()?;
720
721 let node_states = self
722 .store
723 .get_latest_node_states(&session_id)
724 .unwrap_or_default();
725
726 let graph_edges = self
727 .store
728 .get_task_graph_edges(&session_id)
729 .unwrap_or_default();
730
731 let branches = self
732 .store
733 .get_provisional_branches(&session_id)
734 .unwrap_or_default();
735
736 let escalation_reports = self
737 .store
738 .get_escalation_reports(&session_id)
739 .unwrap_or_default();
740
741 let flushes = self
742 .store
743 .get_branch_flushes(&session_id)
744 .unwrap_or_default();
745
746 let mut node_details: Vec<NodeSnapshotDetail> = Vec::with_capacity(node_states.len());
748 for ns in &node_states {
749 let nid = &ns.node_id;
750
751 let energy_history = self
752 .store
753 .get_energy_history(&session_id, nid)
754 .unwrap_or_default();
755
756 let verification = self
757 .store
758 .get_verification_result(&session_id, nid)
759 .ok()
760 .flatten();
761
762 let artifact_bundle = self
763 .store
764 .get_artifact_bundle(&session_id, nid)
765 .ok()
766 .flatten();
767
768 let sheaf_validations = self
769 .store
770 .get_sheaf_validations(&session_id, nid)
771 .unwrap_or_default();
772
773 let interface_seals = self
774 .store
775 .get_interface_seals(&session_id, nid)
776 .unwrap_or_default();
777
778 let context_provenance = self
779 .store
780 .get_context_provenance(&session_id, nid)
781 .ok()
782 .flatten();
783
784 node_details.push(NodeSnapshotDetail {
785 record: ns.clone(),
786 energy_history,
787 verification,
788 artifact_bundle,
789 sheaf_validations,
790 interface_seals,
791 context_provenance,
792 });
793 }
794
795 log::info!(
796 "Loaded session snapshot: {} nodes, {} edges, {} branches",
797 node_details.len(),
798 graph_edges.len(),
799 branches.len()
800 );
801
802 Ok(SessionSnapshot {
803 session_id,
804 node_details,
805 graph_edges,
806 branches,
807 escalation_reports,
808 flushes,
809 })
810 }
811
812 fn session_id(&self) -> Result<String> {
818 self.current_session
819 .as_ref()
820 .map(|s| s.session_id.clone())
821 .context("No active session")
822 }
823
824 pub fn record_provisional_branch(
826 &self,
827 branch: &perspt_core::types::ProvisionalBranch,
828 ) -> Result<()> {
829 let row = perspt_store::ProvisionalBranchRow {
830 branch_id: branch.branch_id.clone(),
831 session_id: branch.session_id.clone(),
832 node_id: branch.node_id.clone(),
833 parent_node_id: branch.parent_node_id.clone(),
834 state: branch.state.to_string(),
835 parent_seal_hash: branch.parent_seal_hash.map(|h| h.to_vec()),
836 sandbox_dir: branch.sandbox_dir.clone(),
837 };
838
839 self.store.record_provisional_branch(&row)?;
840 log::debug!(
841 "Recorded provisional branch '{}' for node '{}' (parent: '{}')",
842 branch.branch_id,
843 branch.node_id,
844 branch.parent_node_id
845 );
846 Ok(())
847 }
848
849 pub fn update_branch_state(&self, branch_id: &str, new_state: &str) -> Result<()> {
851 self.store.update_branch_state(branch_id, new_state)?;
852 log::debug!("Updated branch '{}' state to '{}'", branch_id, new_state);
853 Ok(())
854 }
855
856 pub fn get_provisional_branches(&self) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
858 let session_id = self.session_id()?;
859 self.store.get_provisional_branches(&session_id)
860 }
861
862 pub fn get_live_branches_for_parent(
864 &self,
865 parent_node_id: &str,
866 ) -> Result<Vec<perspt_store::ProvisionalBranchRow>> {
867 let session_id = self.session_id()?;
868 self.store
869 .get_live_branches_for_parent(&session_id, parent_node_id)
870 }
871
872 pub fn flush_branches_for_parent(&self, parent_node_id: &str) -> Result<Vec<String>> {
874 let session_id = self.session_id()?;
875 self.store
876 .flush_branches_for_parent(&session_id, parent_node_id)
877 }
878
879 pub fn record_branch_lineage(&self, lineage: &perspt_core::types::BranchLineage) -> Result<()> {
881 let row = perspt_store::BranchLineageRow {
882 lineage_id: lineage.lineage_id.clone(),
883 parent_branch_id: lineage.parent_branch_id.clone(),
884 child_branch_id: lineage.child_branch_id.clone(),
885 depends_on_seal: lineage.depends_on_seal,
886 };
887
888 self.store.record_branch_lineage(&row)?;
889 log::debug!(
890 "Recorded branch lineage: {} → {}",
891 lineage.parent_branch_id,
892 lineage.child_branch_id
893 );
894 Ok(())
895 }
896
897 pub fn get_child_branches(&self, parent_branch_id: &str) -> Result<Vec<String>> {
899 self.store.get_child_branches(parent_branch_id)
900 }
901
902 pub fn record_interface_seal(
904 &self,
905 seal: &perspt_core::types::InterfaceSealRecord,
906 ) -> Result<()> {
907 let row = perspt_store::InterfaceSealRow {
908 seal_id: seal.seal_id.clone(),
909 session_id: seal.session_id.clone(),
910 node_id: seal.node_id.clone(),
911 sealed_path: seal.sealed_path.clone(),
912 artifact_kind: seal.artifact_kind.to_string(),
913 seal_hash: seal.seal_hash.to_vec(),
914 version: seal.version as i32,
915 };
916
917 self.store.record_interface_seal(&row)?;
918 log::debug!(
919 "Recorded interface seal '{}' for node '{}' at '{}'",
920 seal.seal_id,
921 seal.node_id,
922 seal.sealed_path
923 );
924 Ok(())
925 }
926
927 pub fn get_interface_seals(
929 &self,
930 node_id: &str,
931 ) -> Result<Vec<perspt_store::InterfaceSealRow>> {
932 let session_id = self.session_id()?;
933 self.store.get_interface_seals(&session_id, node_id)
934 }
935
936 pub fn has_interface_seals(&self, node_id: &str) -> Result<bool> {
938 let session_id = self.session_id()?;
939 self.store.has_interface_seals(&session_id, node_id)
940 }
941
942 pub fn record_branch_flush(&self, flush: &perspt_core::types::BranchFlushRecord) -> Result<()> {
944 let row = perspt_store::BranchFlushRow {
945 flush_id: flush.flush_id.clone(),
946 session_id: flush.session_id.clone(),
947 parent_node_id: flush.parent_node_id.clone(),
948 flushed_branch_ids: serde_json::to_string(&flush.flushed_branch_ids)
949 .unwrap_or_default(),
950 requeue_node_ids: serde_json::to_string(&flush.requeue_node_ids).unwrap_or_default(),
951 reason: flush.reason.clone(),
952 };
953
954 self.store.record_branch_flush(&row)?;
955 log::debug!(
956 "Recorded branch flush for parent '{}': {} branches flushed",
957 flush.parent_node_id,
958 flush.flushed_branch_ids.len()
959 );
960 Ok(())
961 }
962
963 pub fn get_branch_flushes(&self) -> Result<Vec<perspt_store::BranchFlushRow>> {
965 let session_id = self.session_id()?;
966 self.store.get_branch_flushes(&session_id)
967 }
968
969 pub fn record_review_outcome(
975 &self,
976 node_id: &str,
977 outcome: &str,
978 reviewer_note: Option<&str>,
979 energy_at_review: Option<f64>,
980 degraded: Option<bool>,
981 escalation_category: Option<&str>,
982 ) -> Result<()> {
983 let session_id = self.session_id()?;
984 let row = perspt_store::ReviewOutcomeRow {
985 session_id,
986 node_id: node_id.to_string(),
987 outcome: outcome.to_string(),
988 reviewer_note: reviewer_note.map(|s| s.to_string()),
989 energy_at_review,
990 degraded,
991 escalation_category: escalation_category.map(|s| s.to_string()),
992 };
993 self.store.record_review_outcome(&row)
994 }
995
996 pub fn get_review_outcomes(
998 &self,
999 node_id: &str,
1000 ) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1001 let session_id = self.session_id()?;
1002 self.store.get_review_outcomes(&session_id, node_id)
1003 }
1004
1005 pub fn get_all_review_outcomes(&self) -> Result<Vec<perspt_store::ReviewOutcomeRow>> {
1007 let session_id = self.session_id()?;
1008 self.store.get_all_review_outcomes(&session_id)
1009 }
1010
1011 pub fn node_review_summary(&self, node_id: &str) -> Result<NodeReviewSummary> {
1021 let session_id = self.session_id()?;
1022
1023 let energy_history = self
1024 .store
1025 .get_energy_history(&session_id, node_id)
1026 .unwrap_or_default();
1027
1028 let latest_energy = energy_history.last().cloned();
1029
1030 let escalation_reports = self
1031 .store
1032 .get_escalation_reports(&session_id)
1033 .unwrap_or_default()
1034 .into_iter()
1035 .filter(|r| r.node_id == node_id)
1036 .collect::<Vec<_>>();
1037
1038 let sheaf_validations = self
1039 .store
1040 .get_sheaf_validations(&session_id, node_id)
1041 .unwrap_or_default();
1042
1043 let interface_seals = self
1044 .store
1045 .get_interface_seals(&session_id, node_id)
1046 .unwrap_or_default();
1047
1048 let context_provenance = self
1049 .store
1050 .get_context_provenance(&session_id, node_id)
1051 .ok()
1052 .flatten()
1053 .into_iter()
1054 .collect::<Vec<_>>();
1055
1056 let branches: Vec<_> = self
1057 .store
1058 .get_provisional_branches(&session_id)
1059 .unwrap_or_default()
1060 .into_iter()
1061 .filter(|b| b.node_id == node_id)
1062 .collect();
1063
1064 let attempt_count = energy_history.len().max(1) as u32;
1065
1066 Ok(NodeReviewSummary {
1067 node_id: node_id.to_string(),
1068 latest_energy,
1069 energy_history,
1070 attempt_count,
1071 escalation_reports,
1072 sheaf_validations,
1073 interface_seals,
1074 context_provenance,
1075 branches,
1076 })
1077 }
1078
1079 pub fn session_summary(&self) -> Result<SessionReviewSummary> {
1082 let session_id = self.session_id()?;
1083
1084 let node_states = self.store.get_node_states(&session_id).unwrap_or_default();
1085 let total_nodes = node_states.len();
1086 let completed = node_states
1087 .iter()
1088 .filter(|n| n.state == "COMPLETED" || n.state == "STABLE")
1089 .count();
1090 let failed = node_states.iter().filter(|n| n.state == "FAILED").count();
1091 let escalated = node_states
1092 .iter()
1093 .filter(|n| n.state == "Escalated")
1094 .count();
1095
1096 let mut total_energy: f32 = 0.0;
1098 let mut node_energies: Vec<(String, perspt_store::EnergyRecord)> = Vec::new();
1099 for ns in &node_states {
1100 if let Ok(history) = self.store.get_energy_history(&session_id, &ns.node_id) {
1101 if let Some(latest) = history.last() {
1102 total_energy += latest.v_total;
1103 node_energies.push((ns.node_id.clone(), latest.clone()));
1104 }
1105 }
1106 }
1107
1108 let escalation_reports = self
1109 .store
1110 .get_escalation_reports(&session_id)
1111 .unwrap_or_default();
1112
1113 let branches = self
1114 .store
1115 .get_provisional_branches(&session_id)
1116 .unwrap_or_default();
1117
1118 let active_branches = branches.iter().filter(|b| b.state == "active").count();
1119 let sealed_branches = branches.iter().filter(|b| b.state == "sealed").count();
1120 let merged_branches = branches.iter().filter(|b| b.state == "merged").count();
1121 let flushed_branches = branches.iter().filter(|b| b.state == "flushed").count();
1122
1123 let flushes = self
1124 .store
1125 .get_branch_flushes(&session_id)
1126 .unwrap_or_default();
1127
1128 let review_outcomes = self
1130 .store
1131 .get_all_review_outcomes(&session_id)
1132 .unwrap_or_default();
1133 let review_total = review_outcomes.len();
1134 let reviews_approved = review_outcomes
1135 .iter()
1136 .filter(|r| r.outcome.starts_with("approved") || r.outcome == "auto_approved")
1137 .count();
1138 let reviews_rejected = review_outcomes
1139 .iter()
1140 .filter(|r| r.outcome == "rejected" || r.outcome == "aborted")
1141 .count();
1142 let reviews_corrected = review_outcomes
1143 .iter()
1144 .filter(|r| r.outcome == "correction_requested")
1145 .count();
1146
1147 Ok(SessionReviewSummary {
1148 session_id,
1149 total_nodes,
1150 completed,
1151 failed,
1152 escalated,
1153 total_energy,
1154 node_energies,
1155 escalation_reports,
1156 branches_total: branches.len(),
1157 active_branches,
1158 sealed_branches,
1159 merged_branches,
1160 flushed_branches,
1161 flush_decisions: flushes,
1162 review_total,
1163 reviews_approved,
1164 reviews_rejected,
1165 reviews_corrected,
1166 })
1167 }
1168}
1169
1170impl MerkleLedger {
1175 pub fn record_feature_charter(&self, charter: &perspt_core::FeatureCharter) -> Result<()> {
1177 let session_id = self.session_id()?;
1178 let row = perspt_store::FeatureCharterRow {
1179 charter_id: charter.charter_id.clone(),
1180 session_id,
1181 scope_description: charter.scope_description.clone(),
1182 max_modules: charter.max_modules.map(|v| v as i32),
1183 max_files: charter.max_files.map(|v| v as i32),
1184 max_revisions: charter.max_revisions.map(|v| v as i32),
1185 language_constraint: charter.language_constraint.clone(),
1186 };
1187 self.store.record_feature_charter(&row)?;
1188 log::debug!("Recorded feature charter '{}'", charter.charter_id);
1189 Ok(())
1190 }
1191
1192 pub fn get_feature_charter(&self) -> Result<Option<perspt_store::FeatureCharterRow>> {
1194 let session_id = self.session_id()?;
1195 self.store.get_feature_charter(&session_id)
1196 }
1197
1198 pub fn record_plan_revision(&self, revision: &perspt_core::PlanRevision) -> Result<()> {
1200 let session_id = self.session_id()?;
1201 let plan_json = serde_json::to_string(&revision.plan).unwrap_or_default();
1202 let row = perspt_store::PlanRevisionRow {
1203 revision_id: revision.revision_id.clone(),
1204 session_id,
1205 sequence: revision.sequence as i32,
1206 plan_json,
1207 reason: revision.reason.clone(),
1208 supersedes: revision.supersedes.clone(),
1209 status: revision.status.to_string(),
1210 };
1211 self.store.record_plan_revision(&row)?;
1212 log::debug!(
1213 "Recorded plan revision '{}' (seq={}, status={})",
1214 revision.revision_id,
1215 revision.sequence,
1216 revision.status
1217 );
1218 Ok(())
1219 }
1220
1221 pub fn get_active_plan_revision(&self) -> Result<Option<perspt_store::PlanRevisionRow>> {
1223 let session_id = self.session_id()?;
1224 self.store.get_active_plan_revision(&session_id)
1225 }
1226
1227 pub fn get_plan_revisions(&self) -> Result<Vec<perspt_store::PlanRevisionRow>> {
1229 let session_id = self.session_id()?;
1230 self.store.get_plan_revisions(&session_id)
1231 }
1232
1233 pub fn supersede_plan_revision(&self, revision_id: &str) -> Result<()> {
1235 self.store.supersede_plan_revision(revision_id)?;
1236 log::debug!("Superseded plan revision '{}'", revision_id);
1237 Ok(())
1238 }
1239
1240 pub fn record_repair_footprint(&self, footprint: &perspt_core::RepairFootprint) -> Result<()> {
1242 let session_id = self.session_id()?;
1243 let row = perspt_store::RepairFootprintRow {
1244 footprint_id: footprint.footprint_id.clone(),
1245 session_id,
1246 node_id: footprint.node_id.clone(),
1247 revision_id: footprint.revision_id.clone(),
1248 attempt: footprint.attempt as i32,
1249 affected_files: serde_json::to_string(&footprint.affected_files).unwrap_or_default(),
1250 bundle_json: serde_json::to_string(&footprint.applied_bundle).unwrap_or_default(),
1251 diagnosis: footprint.diagnosis.clone(),
1252 resolved: footprint.resolved,
1253 };
1254 self.store.record_repair_footprint(&row)?;
1255 log::debug!(
1256 "Recorded repair footprint '{}' for node '{}' (attempt {})",
1257 footprint.footprint_id,
1258 footprint.node_id,
1259 footprint.attempt
1260 );
1261 Ok(())
1262 }
1263
1264 pub fn get_repair_footprints(
1266 &self,
1267 node_id: &str,
1268 ) -> Result<Vec<perspt_store::RepairFootprintRow>> {
1269 let session_id = self.session_id()?;
1270 self.store.get_repair_footprints(&session_id, node_id)
1271 }
1272
1273 pub fn resolve_repair_footprint(&self, footprint_id: &str) -> Result<()> {
1275 self.store.resolve_repair_footprint(footprint_id)?;
1276 log::debug!("Resolved repair footprint '{}'", footprint_id);
1277 Ok(())
1278 }
1279
1280 pub fn upsert_budget_envelope(&self, budget: &perspt_core::BudgetEnvelope) -> Result<()> {
1282 let session_id = self.session_id()?;
1283 let row = perspt_store::BudgetEnvelopeRow {
1284 session_id,
1285 max_steps: budget.max_steps.map(|v| v as i32),
1286 steps_used: budget.steps_used as i32,
1287 max_revisions: budget.max_revisions.map(|v| v as i32),
1288 revisions_used: budget.revisions_used as i32,
1289 max_cost_usd: budget.max_cost_usd,
1290 cost_used_usd: budget.cost_used_usd,
1291 };
1292 self.store.upsert_budget_envelope(&row)?;
1293 log::debug!("Upserted budget envelope for session");
1294 Ok(())
1295 }
1296
1297 pub fn get_budget_envelope(&self) -> Result<Option<perspt_store::BudgetEnvelopeRow>> {
1299 let session_id = self.session_id()?;
1300 self.store.get_budget_envelope(&session_id)
1301 }
1302}
1303
1304#[derive(Debug, Clone)]
1308pub struct NodeReviewSummary {
1309 pub node_id: String,
1310 pub latest_energy: Option<perspt_store::EnergyRecord>,
1311 pub energy_history: Vec<perspt_store::EnergyRecord>,
1312 pub attempt_count: u32,
1313 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1314 pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1315 pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1316 pub context_provenance: Vec<perspt_store::ContextProvenanceRecord>,
1317 pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1318}
1319
1320#[derive(Debug, Clone)]
1324pub struct SessionReviewSummary {
1325 pub session_id: String,
1326 pub total_nodes: usize,
1327 pub completed: usize,
1328 pub failed: usize,
1329 pub escalated: usize,
1330 pub total_energy: f32,
1331 pub node_energies: Vec<(String, perspt_store::EnergyRecord)>,
1332 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1333 pub branches_total: usize,
1334 pub active_branches: usize,
1335 pub sealed_branches: usize,
1336 pub merged_branches: usize,
1337 pub flushed_branches: usize,
1338 pub flush_decisions: Vec<perspt_store::BranchFlushRow>,
1339 pub review_total: usize,
1341 pub reviews_approved: usize,
1342 pub reviews_rejected: usize,
1343 pub reviews_corrected: usize,
1344}
1345
1346#[derive(Debug, Clone)]
1348pub struct LedgerStats {
1349 pub total_sessions: usize,
1350 pub total_commits: usize,
1351 pub db_size_bytes: u64,
1352}
1353
1354#[derive(Debug, Clone)]
1356pub struct NodeSnapshotDetail {
1357 pub record: NodeStateRecord,
1358 pub energy_history: Vec<perspt_store::EnergyRecord>,
1359 pub verification: Option<perspt_store::VerificationResultRow>,
1360 pub artifact_bundle: Option<perspt_store::ArtifactBundleRow>,
1361 pub sheaf_validations: Vec<perspt_store::SheafValidationRow>,
1362 pub interface_seals: Vec<perspt_store::InterfaceSealRow>,
1363 pub context_provenance: Option<perspt_store::ContextProvenanceRecord>,
1364}
1365
1366#[derive(Debug, Clone)]
1372pub struct SessionSnapshot {
1373 pub session_id: String,
1374 pub node_details: Vec<NodeSnapshotDetail>,
1375 pub graph_edges: Vec<perspt_store::TaskGraphEdgeRow>,
1376 pub branches: Vec<perspt_store::ProvisionalBranchRow>,
1377 pub escalation_reports: Vec<perspt_store::EscalationReportRecord>,
1378 pub flushes: Vec<perspt_store::BranchFlushRow>,
1379}
1380
1381fn generate_commit_id() -> String {
1383 use std::time::{SystemTime, UNIX_EPOCH};
1384 let now = SystemTime::now()
1385 .duration_since(UNIX_EPOCH)
1386 .unwrap()
1387 .as_nanos();
1388 format!("{:x}", now)
1389}
1390
1391fn chrono_timestamp() -> i64 {
1393 use std::time::{SystemTime, UNIX_EPOCH};
1394 SystemTime::now()
1395 .duration_since(UNIX_EPOCH)
1396 .unwrap()
1397 .as_secs() as i64
1398}
1399
1400fn chrono_iso_now() -> String {
1402 use std::time::{SystemTime, UNIX_EPOCH};
1403 let secs = SystemTime::now()
1404 .duration_since(UNIX_EPOCH)
1405 .unwrap()
1406 .as_secs();
1407 let days = secs / 86400;
1409 let time = secs % 86400;
1410 let h = time / 3600;
1411 let m = (time % 3600) / 60;
1412 let s = time % 60;
1413 let (y, mo, d) = days_to_ymd(days);
1415 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, m, s)
1416}
1417
1418fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1420 let z = days + 719468;
1422 let era = z / 146097;
1423 let doe = z - era * 146097;
1424 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1425 let y = yoe + era * 400;
1426 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1427 let mp = (5 * doy + 2) / 153;
1428 let d = doy - (153 * mp + 2) / 5 + 1;
1429 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1430 let y = if m <= 2 { y + 1 } else { y };
1431 (y, m, d)
1432}