Skip to main content

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/// Full commit payload collected by the orchestrator at commit time.
10///
11/// Bundles graph-structural fields, retry/error metadata, and merkle
12/// material so that `commit_node_snapshot()` can persist a complete
13/// node record in a single call.
14#[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    /// JSON-serialized `Vec<String>` of child node IDs
26    pub children: Option<String>,
27    pub last_error_type: Option<String>,
28}
29
30/// Merkle commit record (Legacy wrapper for compatibility)
31#[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/// Session record (Legacy wrapper for compatibility)
44#[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
55/// Merkle Ledger using DuckDB for persistence
56pub struct MerkleLedger {
57    /// Session store from perspt-store
58    store: SessionStore,
59    /// Current session metadata (legacy cache)
60    pub(crate) current_session: Option<SessionRecordLegacy>,
61    /// Session artifact directory
62    session_dir: Option<PathBuf>,
63}
64
65impl MerkleLedger {
66    /// Create a new ledger (opens or creates database)
67    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    /// Create an in-memory ledger (for testing)
77    pub fn in_memory() -> Result<Self> {
78        // Use a unique temp db for testing to avoid collisions
79        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    /// Start a new session
90    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        // Create physical artifact directory
103        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    /// Record energy measurement
122    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    /// Commit a stable node state
150    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, // Placeholder
173            // Phase 8 fields — populated properly via commit_node_snapshot
174            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        // Update session progress
189        if let Some(ref mut session) = self.current_session {
190            session.completed_nodes += 1;
191        }
192
193        Ok(commit_id)
194    }
195
196    /// Commit a full node snapshot with all Phase 8 metadata.
197    ///
198    /// This is the preferred commit API for the orchestrator. It records the
199    /// complete node state, graph-structural fields, retry/error metadata,
200    /// and merkle material in a single durable write. Returns the commit ID.
201    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        // Update merkle root if hash is present
229        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    /// End the current session
252    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            // Persist status to durable store
257            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    /// Get artifacts directory
269    pub fn artifacts_dir(&self) -> Option<&Path> {
270        self.session_dir.as_deref()
271    }
272
273    /// Get session statistics (legacy facade)
274    pub fn get_stats(&self) -> LedgerStats {
275        LedgerStats {
276            total_sessions: 0, // Would query store.count_sessions()
277            total_commits: 0,
278            db_size_bytes: 0,
279        }
280    }
281
282    /// Get the current merkle root (legacy facade)
283    pub fn current_merkle_root(&self) -> [u8; 32] {
284        [0u8; 32] // Placeholder
285    }
286
287    /// Record an LLM request/response for debugging and cost tracking
288    #[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    /// Record lightweight LLM usage metrics (no prompt/response text).
327    ///
328    /// This is always called after every LLM invocation regardless of
329    /// the `--log-llm` flag so that token and cost accounting is never lost.
330    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        // Re-use existing table with empty prompt/response to avoid schema changes.
345        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    /// Get access to the underlying store (for direct queries)
368    pub fn store(&self) -> &SessionStore {
369        &self.store
370    }
371
372    // =========================================================================
373    // PSP-5 Phase 3: Structural Digests & Context Provenance
374    // =========================================================================
375
376    /// Record a structural digest for a node
377    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    /// Record context provenance for a node
411    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    /// Get context provenance for a specific node in the current session
462    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    // =========================================================================
476    // PSP-5 Phase 5: Escalation and Rewrite Persistence
477    // =========================================================================
478
479    /// Record an escalation report for a non-convergent node
480    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    /// Record a local graph rewrite
512    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    /// PSP-5 Phase 5: Count rewrite records matching a lineage prefix.
540    ///
541    /// A lineage is identified by the base node ID (before any `__split_` or
542    /// `__iface` suffixes). This count is used as a churn guardrail to prevent
543    /// infinite rewrite loops.
544    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    /// Record a sheaf validation result
560    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    /// Get escalation reports for the current session
594    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    // =========================================================================
605    // PSP-5 Phase 8: Verification Result and Artifact Bundle Facades
606    // =========================================================================
607
608    /// Record a verification result snapshot for a node
609    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    /// Get the latest verification result for a node
645    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    /// Record an artifact bundle snapshot for a node
654    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    /// Get the latest artifact bundle for a node
688    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    // =========================================================================
697    // PSP-5 Phase 8: Task Graph & Session Rehydration
698    // =========================================================================
699
700    /// Record a task-graph edge (parent→child dependency)
701    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    /// Get all task graph edges for the current session
725    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    /// Get sheaf validations for a specific node
731    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    /// Load a complete session snapshot for rehydration/resume.
740    ///
741    /// Aggregates the latest node states, graph topology, energy history,
742    /// verification results, artifact bundles, sheaf validations,
743    /// provisional branches, interface seals, context provenance, and
744    /// escalation reports into a single `SessionSnapshot`.
745    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        // Collect per-node evidence
774        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    // =========================================================================
840    // PSP-5 Phase 6: Provisional Branch, Interface Seal, Branch Flush Facades
841    // =========================================================================
842
843    /// Get the current session ID (helper for Phase 6 methods)
844    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    /// Record a new provisional branch for speculative child work
852    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    /// Update a provisional branch state
877    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    /// Get all provisional branches for the current session
884    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    /// Get live (active/sealed) branches depending on a parent node
890    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    /// Flush all live branches for a parent node and return flushed branch IDs
900    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    /// Record a branch lineage edge (parent branch → child branch)
907    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    /// Record an interface seal for a node
925    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    /// Get all interface seals for a node in the current session
950    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    /// Record a branch flush decision
959    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    /// Get all branch flush records for the current session
980    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    // =========================================================================
986    // PSP-5 Phase 7: Review Outcome Persistence
987    // =========================================================================
988
989    /// Persist a review decision as an audit record.
990    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    /// Get all review outcomes for a node.
1013    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    /// Get all review outcomes across the session.
1022    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    // =========================================================================
1028    // PSP-5 Phase 7: Shared Review & Provenance Aggregation Helpers
1029    // =========================================================================
1030
1031    /// Build a review-ready summary for a single node.
1032    ///
1033    /// Aggregates energy history, escalation reports, sheaf validations,
1034    /// context provenance, interface seals, and branch state from the store
1035    /// into a single struct consumable by both TUI and CLI surfaces.
1036    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    /// Build a session-level summary aggregating lifecycle counts, energy
1096    /// stats, escalation activity, and branch provenance.
1097    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        // Collect latest energy per node
1113        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        // Review audit aggregation
1145        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
1186// =========================================================================
1187// Plan Revision, Feature Charter, Repair Footprint, Budget Envelope Facades
1188// =========================================================================
1189
1190impl MerkleLedger {
1191    /// Record a feature charter for the current session.
1192    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    /// Get the feature charter for the current session.
1209    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    /// Record a plan revision for the current session.
1215    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    /// Get the active plan revision for the current session.
1238    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    /// Get all plan revisions for the current session.
1244    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    /// Supersede a plan revision by ID.
1250    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    /// Record a repair footprint for a node.
1257    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    /// Get repair footprints for a node in the current session.
1281    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    /// Record or update the budget envelope for the current session.
1290    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    /// Get the budget envelope for the current session.
1307    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    // =====================================================================
1313    // PSP-7: SRBN step records and correction attempt wrappers
1314    // =====================================================================
1315
1316    /// Record an orchestration step transition for the current session.
1317    pub fn record_step(&self, record: &perspt_store::SrbnStepRecord) -> Result<()> {
1318        self.store.record_step(record)
1319    }
1320
1321    /// Retrieve the step timeline for a node in the current session.
1322    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    /// Retrieve all step records for the current session.
1328    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    /// Record a correction attempt for the current session.
1334    pub fn record_correction_attempt(
1335        &self,
1336        record: &perspt_store::CorrectionAttemptRow,
1337    ) -> Result<()> {
1338        self.store.record_correction_attempt(record)
1339    }
1340
1341    /// Retrieve all correction attempts for a node in the current session.
1342    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/// PSP-5 Phase 7: Aggregated review summary for a single node.
1352///
1353/// Consumed by both TUI review modal and CLI status/resume commands.
1354#[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/// PSP-5 Phase 7: Aggregated session-level review summary.
1368///
1369/// Consumed by both TUI dashboard and CLI status/resume commands.
1370#[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    /// Review audit: total decisions and breakdown
1387    pub review_total: usize,
1388    pub reviews_approved: usize,
1389    pub reviews_rejected: usize,
1390    pub reviews_corrected: usize,
1391}
1392
1393/// Ledger statistics (Legacy)
1394#[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/// PSP-5 Phase 8: Per-node evidence bundle for session rehydration.
1402#[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/// PSP-5 Phase 8: Complete session snapshot for resume/rehydration.
1414///
1415/// Aggregates all persisted state needed to reconstruct the orchestrator
1416/// DAG, restore node states, and resume execution from the last durable
1417/// boundary.
1418#[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
1428/// Generate a unique commit ID
1429fn 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
1438/// Get current timestamp
1439fn 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
1447/// ISO-8601 timestamp for committed_at fields
1448fn 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    // Simple UTC timestamp — YYYY-MM-DDTHH:MM:SSZ
1455    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    // Days since 1970-01-01 to y/m/d (civil calendar)
1461    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
1465/// Convert days since Unix epoch to (year, month, day)
1466fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1467    // Algorithm from Howard Hinnant's date library
1468    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}