diff --git a/Dockerfile b/Dockerfile index a9d226b..0e4582b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ WORKDIR /app COPY --from=builder /app/target/release/ledgerr-mcp-server /usr/local/bin/ledgerr-mcp-server -ENV LEDGER_WORKBOOK_PATH=/data/tax-ledger.xlsx +ENV LEDGERR_WORKBOOK_PATH=/data/tax-ledger.xlsx ENV LEDGER_PDF_INBOX=/data/inbox CMD ["/usr/local/bin/ledgerr-mcp-server"] diff --git a/Dockerfile.ledgerr-mcp b/Dockerfile.ledgerr-mcp index 06c0864..f121e1d 100644 --- a/Dockerfile.ledgerr-mcp +++ b/Dockerfile.ledgerr-mcp @@ -6,5 +6,5 @@ RUN cargo build --release -p ledgerr-mcp FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/ledgerr-mcp-server /usr/local/bin/ledgerr-mcp -EXPOSE 8001 +# NOTE: No EXPOSE — this MCP server uses stdio transport, not TCP. ENTRYPOINT ["/usr/local/bin/ledgerr-mcp"] diff --git a/Justfile b/Justfile index c709f45..ead6b25 100644 --- a/Justfile +++ b/Justfile @@ -664,3 +664,11 @@ wrkflw-full-test emulation="secure-emulation": @echo "" @echo "=== Step 2: Run docgen pipeline ===" wrkflw run --runtime {{emulation}} .github/workflows/wrkflw-docgen.yml + +# Verify all env vars in source code are documented in .env.example +env-docs-check: + bash scripts/check-env-docs.sh + +# Timed b00t maintenance probe for version/task/focus/audit surfaces. +b00t-maintenance-check budget="": + bash scripts/check-b00t-maintenance.sh {{ if budget == "" { "" } else { "--budget " + budget } }} diff --git a/README.md b/README.md index 8c839f0..087c888 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # l3dg3rr +> **Note on naming:** This project uses multiple names (`l3dg3rr`, `ledgerr-mcp`, `ledgrrr`, `ledg3rr`) depending on context. See [POLYSEME-MAP.md](docs/POLYSEME-MAP.md) for the mapping. + [![CI](https://github.com/PromptExecution/l3dg3rr/actions/workflows/ci.yml/badge.svg)](https://github.com/PromptExecution/l3dg3rr/actions/workflows/ci.yml) [![Release](https://github.com/PromptExecution/l3dg3rr/actions/workflows/release.yml/badge.svg)](https://github.com/PromptExecution/l3dg3rr/actions/workflows/release.yml) [![Documentation](https://img.shields.io/badge/docs-github.io-blue)](https://promptexecution.github.io/l3dg3rr/) diff --git a/crates/arc-kit-au/src/graph.rs b/crates/arc-kit-au/src/graph.rs index 012b083..912f122 100644 --- a/crates/arc-kit-au/src/graph.rs +++ b/crates/arc-kit-au/src/graph.rs @@ -255,10 +255,9 @@ impl Default for EvidenceGraph { #[cfg(test)] mod tests { use super::*; - use crate::node::{Classification, SourceDoc, Transaction}; + use crate::node::{SourceDoc, Transaction}; use chrono::TimeZone; use chrono::Utc; - use rust_decimal::Decimal; fn test_doc() -> SourceDoc { SourceDoc { diff --git a/crates/holon-viz/Cargo.toml b/crates/holon-viz/Cargo.toml index d59e7ef..7195b39 100644 --- a/crates/holon-viz/Cargo.toml +++ b/crates/holon-viz/Cargo.toml @@ -12,7 +12,6 @@ blake3.workspace = true chrono.workspace = true thiserror.workspace = true tracing.workspace = true -specta = { version = "=2.0.0-rc.25", features = ["derive"] } [[bin]] name = "holon-viz-demo" @@ -21,6 +20,6 @@ path = "src/bin/demo.rs" [dev-dependencies] serde_json.workspace = true tempfile.workspace = true - -[dev-dependencies] -serde_json.workspace = true +# CDP integration test dependency — only required when TEST_WEBVIEW=1 is set +# cargo test --test holon_viz_visual_e2e # e2e with live Tauri WebView2 +# cargo test # unit + integration (no webview) diff --git a/crates/holon-viz/src/cytoscape.rs b/crates/holon-viz/src/cytoscape.rs index d82a602..e351e3c 100644 --- a/crates/holon-viz/src/cytoscape.rs +++ b/crates/holon-viz/src/cytoscape.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::holon::Holon; /// Data payload for a Cytoscape.js node element. -#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CytoscapeNodeData { pub id: String, pub label: String, @@ -26,13 +26,13 @@ pub struct CytoscapeNodeData { } /// A single Cytoscape.js node element. -#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CytoscapeNode { pub data: CytoscapeNodeData, } /// Data payload for a Cytoscape.js edge element. -#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CytoscapeEdgeData { pub id: String, pub source: String, @@ -41,7 +41,7 @@ pub struct CytoscapeEdgeData { } /// A single Cytoscape.js edge element. -#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CytoscapeEdge { pub data: CytoscapeEdgeData, } @@ -49,7 +49,7 @@ pub struct CytoscapeEdge { /// Serializable Cytoscape.js graph — nodes and edges with `data` fields. /// /// Construct via [`HolonGraph::from_holons`]. -#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CytoscapeGraph { pub nodes: Vec, pub edges: Vec, diff --git a/crates/holon-viz/src/lib.rs b/crates/holon-viz/src/lib.rs index de27fa1..c169faf 100644 --- a/crates/holon-viz/src/lib.rs +++ b/crates/holon-viz/src/lib.rs @@ -26,7 +26,7 @@ pub mod gen; pub mod type_graph; pub use controller::{ProcessController, ProcessStep, TransitionReceipt}; -pub use cytoscape::{CytoscapeEdge, CytoscapeGraph, CytoscapeNode}; +pub use cytoscape::{CytoscapeEdge, CytoscapeEdgeData, CytoscapeGraph, CytoscapeNode, CytoscapeNodeData}; pub use emitter::{Owl2Emitter, SysmlV2Emitter}; pub use holon::{Holon, HolonKind}; pub use log::{ActionKind, ActionRecord, ImmutableActionLog}; diff --git a/crates/holon-viz/src/type_graph.rs b/crates/holon-viz/src/type_graph.rs index c419f84..7a8f4e7 100644 --- a/crates/holon-viz/src/type_graph.rs +++ b/crates/holon-viz/src/type_graph.rs @@ -9,7 +9,7 @@ use crate::cytoscape::{ }; /// A Rust type node suitable for type relationship visualization. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct TypeNode { /// Stable type identifier, usually a fully-qualified Rust path. pub id: String, @@ -30,7 +30,7 @@ pub struct TypeNode { /// Supported relationship kinds between Rust types. #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, specta::Type, + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, )] #[serde(rename_all = "snake_case")] pub enum TypeRelationshipKind { @@ -74,7 +74,7 @@ impl TypeRelationshipKind { /// A directed relationship between two type nodes. #[derive( - Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, specta::Type, + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, )] pub struct TypeRelationship { pub source: String, @@ -101,7 +101,7 @@ impl TypeRelationship { } /// Serializable Rust type relationship graph. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TypeRelationshipGraph { pub nodes: Vec, pub relationships: Vec, diff --git a/crates/holon-viz/tests/holon_viz_comprehensive.rs b/crates/holon-viz/tests/holon_viz_comprehensive.rs new file mode 100644 index 0000000..df8bf8b --- /dev/null +++ b/crates/holon-viz/tests/holon_viz_comprehensive.rs @@ -0,0 +1,950 @@ +//! Comprehensive MECE (Mutually Exclusive, Collectively Exhaustive) integration +//! tests for the holon-viz crate. 42 tests covering all public modules, +//! edge cases, error boundaries, and full-pipeline integration. +//! +//! Test numbering follows the MECE matrix in the task specification. + +#![allow(unused_imports, dead_code)] + +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; + +use holon_viz::{ + ActionKind, ActionRecord, CytoscapeEdge, CytoscapeGraph, CytoscapeNode, Holon, HolonError, + HolonKind, HtmlRenderer, ImmutableActionLog, Owl2Emitter, ProcessController, ProcessStep, + SysmlV2Emitter, TransitionReceipt, TypeNode, TypeRelationship, TypeRelationshipGraph, + TypeRelationshipKind, VizObservation, VizObserver, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_step(id: &str) -> ProcessStep { + ProcessStep { + step_id: id.to_string(), + description: format!("Step {}", id), + } +} + +fn empty_graph() -> CytoscapeGraph { + CytoscapeGraph { + nodes: vec![], + edges: vec![], + } +} + +fn two_node_graph() -> CytoscapeGraph { + let holons = vec![ + Holon { + id: "a".to_string(), + label: "Alpha".to_string(), + kind: HolonKind::SysmlBlock, + parent_id: None, + children: vec!["b".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "b".to_string(), + label: "Beta".to_string(), + kind: HolonKind::OwlClass, + parent_id: Some("a".to_string()), + children: vec![], + metadata: HashMap::new(), + }, + ]; + CytoscapeGraph::from_holons(&holons) +} + +fn three_level_tree() -> Vec { + vec![ + Holon { + id: "root".to_string(), + label: "Root".to_string(), + kind: HolonKind::CapsuleGroup, + parent_id: None, + children: vec!["c1".to_string(), "c2".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "c1".to_string(), + label: "Child1".to_string(), + kind: HolonKind::SysmlBlock, + parent_id: Some("root".to_string()), + children: vec!["gc1".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "c2".to_string(), + label: "Child2".to_string(), + kind: HolonKind::ProcessNode, + parent_id: Some("root".to_string()), + children: vec![], + metadata: HashMap::new(), + }, + Holon { + id: "gc1".to_string(), + label: "GrandChild1".to_string(), + kind: HolonKind::AuditEvent, + parent_id: Some("c1".to_string()), + children: vec![], + metadata: HashMap::new(), + }, + ] +} + +// =========================================================================== +// Module 1: ProcessController (tests 1–7) +// =========================================================================== + +#[test] +fn test_01_empty_controller() { + let ctrl = ProcessController::new(); + assert!(ctrl.steps().is_empty()); + assert!(ctrl.log().is_empty()); + assert_eq!(ctrl.log().len(), 0); +} + +#[test] +fn test_02_full_lifecycle() { + let mut ctrl = ProcessController::new(); + ctrl.register_step(make_step("a")).unwrap(); + ctrl.register_step(make_step("b")).unwrap(); + ctrl.register_step(make_step("c")).unwrap(); + assert_eq!(ctrl.steps().len(), 3); + + let r1 = ctrl.authorize_step("a", "alice", 1000).unwrap(); + let r2 = ctrl.authorize_step("b", "bob", 2000).unwrap(); + let r3 = ctrl.authorize_step("c", "carol", 3000).unwrap(); + + assert_eq!(r1.step_id, "a"); + assert_eq!(r2.step_id, "b"); + assert_eq!(r3.step_id, "c"); + assert_eq!(r1.authorized_by, "alice"); + assert_eq!(r2.authorized_by, "bob"); + assert_eq!(r3.authorized_by, "carol"); + + // Log should have 3 entries in order + assert_eq!(ctrl.log().len(), 3); + let kinds: Vec = ctrl.log().iter().map(|r| r.action_kind).collect(); + assert_eq!( + kinds, + vec![ActionKind::StepAuthorized, ActionKind::StepAuthorized, ActionKind::StepAuthorized] + ); +} + +#[test] +fn test_03_branching_fork() { + let mut ctrl = ProcessController::new(); + ctrl.register_step(make_step("A")).unwrap(); + ctrl.register_step(make_step("B")).unwrap(); + ctrl.register_step(make_step("C")).unwrap(); + + // Fork: A transitions independently to both B and C paths + let r_a1 = ctrl.authorize_step("A", "alice", 100).unwrap(); + let r_b = ctrl.authorize_step("B", "bob", 200).unwrap(); + let r_a2 = ctrl.authorize_step("A", "carol", 300).unwrap(); + let r_c = ctrl.authorize_step("C", "dave", 400).unwrap(); + + assert_eq!(r_a1.step_id, "A"); + assert_eq!(r_b.step_id, "B"); + assert_eq!(r_a2.step_id, "A"); + assert_eq!(r_c.step_id, "C"); + + // Authorizing the same step twice from different authorizers is permitted + assert_ne!(r_a1.authorization_hash, r_a2.authorization_hash); + + assert_eq!(ctrl.log().len(), 4); +} + +#[test] +fn test_04_fork_merge() { + let mut ctrl = ProcessController::new(); + ctrl.register_step(make_step("A")).unwrap(); + ctrl.register_step(make_step("B")).unwrap(); + ctrl.register_step(make_step("C")).unwrap(); + ctrl.register_step(make_step("D")).unwrap(); + + // Fork: A -> B and A -> C + let _r_a_by_alice = ctrl.authorize_step("A", "alice", 100).unwrap(); + let _r_b_by_alice = ctrl.authorize_step("B", "alice", 200).unwrap(); + let _r_a_by_bob = ctrl.authorize_step("A", "bob", 300).unwrap(); + let _r_c_by_bob = ctrl.authorize_step("C", "bob", 400).unwrap(); + + // Merge: both B and C paths authorize D + let r_d_by_alice = ctrl.authorize_step("D", "alice", 500).unwrap(); + let r_d_by_bob = ctrl.authorize_step("D", "bob", 600).unwrap(); + + assert_eq!(r_d_by_alice.step_id, "D"); + assert_eq!(r_d_by_bob.step_id, "D"); + // Same step, different authorizers → different hashes + assert_ne!(r_d_by_alice.authorization_hash, r_d_by_bob.authorization_hash); + + // Total log: A(×2) + B + C + D(×2) = 6 entries + assert_eq!(ctrl.log().len(), 6); + // Verify D appears at the end + let last_two: Vec<&str> = ctrl + .log() + .iter() + .skip(4) + .map(|r| r.authorized_by.as_str()) + .collect(); + assert_eq!(last_two, vec!["alice", "bob"]); +} + +#[test] +fn test_05_empty_step_id_rejection() { + let mut ctrl = ProcessController::new(); + let err = ctrl.register_step(ProcessStep { + step_id: "".to_string(), + description: "empty".to_string(), + }); + assert!(matches!(err, Err(HolonError::EmptyStepId))); +} + +#[test] +fn test_06_empty_authorizer_rejection() { + let mut ctrl = ProcessController::new(); + ctrl.register_step(make_step("x")).unwrap(); + let err = ctrl.authorize_step("x", "", 42); + assert!(matches!(err, Err(HolonError::EmptyAuthorizer))); +} + +#[test] +fn test_07_deterministic_hash_across_sessions() { + // Same inputs → same Blake3 authorization hash across independent controllers + let mut ctrl_a = ProcessController::new(); + let mut ctrl_b = ProcessController::new(); + ctrl_a.register_step(make_step("alpha")).unwrap(); + ctrl_b.register_step(make_step("alpha")).unwrap(); + + let r_a = ctrl_a.authorize_step("alpha", "alice", 999).unwrap(); + let r_b = ctrl_b.authorize_step("alpha", "alice", 999).unwrap(); + + assert_eq!(r_a.authorization_hash, r_b.authorization_hash); +} + +// =========================================================================== +// Module 2: ImmutableActionLog (tests 8–12) +// =========================================================================== + +#[test] +fn test_08_empty_log() { + let log = ImmutableActionLog::new(); + assert!(log.is_empty()); + assert_eq!(log.len(), 0); + assert!(log.iter().next().is_none()); +} + +#[test] +fn test_09_append_only_invariant() { + let mut log = ImmutableActionLog::new(); + let payload = [0u8; 32]; + log.append(ActionKind::StepAuthorized, "alice", 100, payload); + log.append(ActionKind::HolonCreated, "bob", 200, payload); + + assert_eq!(log.len(), 2); + // Cannot remove or modify — there is no remove/update API. + // Verify records are in append order. + let by: Vec<&str> = log.iter().map(|r| r.authorized_by.as_str()).collect(); + assert_eq!(by, vec!["alice", "bob"]); +} + +#[test] +fn test_10_find_by_id() { + let mut log = ImmutableActionLog::new(); + let payload = [0xabu8; 32]; + let record = log + .append(ActionKind::AuditEvent, "carol", 300, payload) + .clone(); + + let found = log.find_by_id(&record.id); + assert!(found.is_some()); + let f = found.unwrap(); + assert_eq!(f.action_kind, ActionKind::AuditEvent); + assert_eq!(f.authorized_by, "carol"); + assert_eq!(f.timestamp_ms, 300); + assert_eq!(f.payload_hash, payload); +} + +#[test] +fn test_11_find_by_id_not_found() { + let log = ImmutableActionLog::new(); + let random_hash = [0x42u8; 32]; + assert!(log.find_by_id(&random_hash).is_none()); +} + +#[test] +fn test_12_deterministic_ids() { + // Identical inputs produce identical record IDs across independent logs + let mut log_a = ImmutableActionLog::new(); + let mut log_b = ImmutableActionLog::new(); + let payload = [0x77u8; 32]; + + let r_a = log_a + .append(ActionKind::StepAuthorized, "deterministic", 500, payload) + .clone(); + let r_b = log_b + .append(ActionKind::StepAuthorized, "deterministic", 500, payload) + .clone(); + + assert_eq!(r_a.id, r_b.id); + assert_eq!(r_a.action_kind, r_b.action_kind); + assert_eq!(r_a.authorized_by, r_b.authorized_by); +} + +// =========================================================================== +// Module 3: Holon (tests 13–16) +// =========================================================================== + +#[test] +fn test_13_root_construction() { + let h = Holon::root("r1", "Root One", HolonKind::CapsuleGroup); + assert_eq!(h.id, "r1"); + assert_eq!(h.label, "Root One"); + assert_eq!(h.kind, HolonKind::CapsuleGroup); + assert!(h.parent_id.is_none()); + assert!(h.children.is_empty()); + assert!(h.metadata.is_empty()); +} + +#[test] +fn test_14_child_construction() { + let h = Holon::child("c1", "Child One", HolonKind::ProcessNode, "parent1"); + assert_eq!(h.id, "c1"); + assert_eq!(h.label, "Child One"); + assert_eq!(h.kind, HolonKind::ProcessNode); + assert_eq!(h.parent_id, Some("parent1".to_string())); + assert!(h.children.is_empty()); +} + +#[test] +fn test_15_metadata_round_trip() { + let mut h = Holon::root("m1", "Meta Test", HolonKind::SysmlBlock); + h.metadata + .insert("version".to_string(), serde_json::json!("v1.0")); + h.metadata + .insert("count".to_string(), serde_json::json!(42)); + + let json = serde_json::to_string(&h).unwrap(); + let deserialized: Holon = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.id, "m1"); + assert_eq!(deserialized.metadata["version"], "v1.0"); + assert_eq!(deserialized.metadata["count"], 42); +} + +#[test] +fn test_16_max_depth_chain() { + // Build a chain of 1000 holons where each is a child of the previous + let mut holons = Vec::with_capacity(1000); + let root = Holon::root("h_0", "Chain Root", HolonKind::ProcessNode); + holons.push(root); + + for i in 1..1000 { + let parent_id = format!("h_{}", i - 1); + let child = Holon::child( + format!("h_{}", i), + format!("Chain Node {}", i), + HolonKind::ProcessNode, + &parent_id, + ); + holons.push(child); + // Update parent's children list + if let Some(parent) = holons.iter_mut().find(|h: &&mut Holon| h.id == parent_id) { + parent.children.push(format!("h_{}", i)); + } + } + + assert_eq!(holons.len(), 1000); + // Verify root has no parent + assert!(holons[0].parent_id.is_none()); + // Verify last node has parent + assert_eq!(holons[999].parent_id, Some("h_998".to_string())); + // Verify root has one child + assert_eq!(holons[0].children.len(), 1); + assert_eq!(holons[0].children[0], "h_1"); +} + +// =========================================================================== +// Module 4: CytoscapeGraph (tests 17–22) +// =========================================================================== + +#[test] +fn test_17_empty_holons() { + let g = CytoscapeGraph::from_holons(&[]); + assert!(g.nodes.is_empty()); + assert!(g.edges.is_empty()); +} + +#[test] +fn test_18_single_holon() { + let h = Holon::root("singleton", "Solo", HolonKind::OwlClass); + let g = CytoscapeGraph::from_holons(&[h]); + assert_eq!(g.nodes.len(), 1); + assert_eq!(g.nodes[0].data.id, "singleton"); + assert_eq!(g.nodes[0].data.label, "Solo"); + assert!(g.edges.is_empty()); +} + +#[test] +fn test_19_parent_child() { + let h = two_node_graph(); + assert_eq!(h.nodes.len(), 2); + assert_eq!(h.edges.len(), 1); + assert_eq!(h.edges[0].data.label, "contains"); + assert_eq!(h.edges[0].data.source, "a"); + assert_eq!(h.edges[0].data.target, "b"); +} + +#[test] +fn test_20_deep_tree() { + let holons = three_level_tree(); + let g = CytoscapeGraph::from_holons(&holons); + + // 4 nodes + assert_eq!(g.nodes.len(), 4); + let node_ids: Vec<&str> = g.nodes.iter().map(|n| n.data.id.as_str()).collect(); + assert_eq!(node_ids, vec!["root", "c1", "c2", "gc1"]); + + // 3 edges: root→c1, root→c2, c1→gc1 + assert_eq!(g.edges.len(), 3); + assert_eq!(g.edges[0].data.id, "root__contains__c1"); + assert_eq!(g.edges[1].data.id, "root__contains__c2"); + assert_eq!(g.edges[2].data.id, "c1__contains__gc1"); +} + +#[test] +fn test_21_json_round_trip() { + let g = two_node_graph(); + let json = g.to_json().unwrap(); + let deserialized: CytoscapeGraph = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.nodes.len(), 2); + assert_eq!(deserialized.edges.len(), 1); + // Preserved fields + assert_eq!(deserialized.nodes[0].data.label, "Alpha"); + assert_eq!(deserialized.edges[0].data.label, "contains"); +} + +#[test] +fn test_22_pretty_json_round_trip() { + let g = two_node_graph(); + let pretty = g.to_json_pretty().unwrap(); + // Pretty JSON should be parseable and contain newlines + assert!(pretty.contains('\n')); + let parsed: CytoscapeGraph = serde_json::from_str(&pretty).unwrap(); + assert_eq!(parsed.nodes.len(), 2); + assert_eq!(parsed.edges.len(), 1); +} + +// =========================================================================== +// Module 5: HtmlRenderer (tests 23–26) +// =========================================================================== + +#[test] +fn test_23_empty_graph_renders() { + let html = HtmlRenderer::render(&empty_graph()); + assert!(html.contains("GRAPH_DATA")); + assert!(html.contains("")); + assert!(html.contains("cytoscape.min.js")); + // Empty graph should have empty arrays + assert!(html.contains("\"nodes\": []") || html.contains("\"nodes\":[]")); +} + +#[test] +fn test_24_special_chars_in_labels() { + let h = Holon::root( + "special", + "Label with <>&\"' and spaces", + HolonKind::SysmlBlock, + ); + let g = CytoscapeGraph::from_holons(&[h]); + let html = HtmlRenderer::render(&g); + // The label should appear inside the GRAPH_DATA JSON + assert!(html.contains("Label with")); + // The JSON serialization escapes quotes correctly + assert!(html.contains("&") || html.contains("&")); +} + +#[test] +fn test_25_write_to_file() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("test.html"); + let g = two_node_graph(); + HtmlRenderer::write_to_file(&g, &path).expect("write_to_file"); + assert!(path.exists(), "file should exist after write_to_file"); + let contents = std::fs::read_to_string(&path).expect("read back"); + assert!(contents.contains("Alpha")); + assert!(contents.contains("Beta")); + assert!(contents.contains("GRAPH_DATA")); +} + +#[test] +fn test_26_nested_parent_renders_compounds() { + let holons = three_level_tree(); + let g = CytoscapeGraph::from_holons(&holons); + let html = HtmlRenderer::render(&g); + // HTML should contain parent info in the JSON data + assert!(html.contains("\"parent\":\"root\"") || html.contains("\"parent\": \"root\"")); + assert!(html.contains("Root")); + assert!(html.contains("GrandChild1")); +} + +// =========================================================================== +// Module 6: Emitters (tests 27–30) +// =========================================================================== + +#[test] +fn test_27_sysml_v2_empty() { + let out = SysmlV2Emitter::emit(&empty_graph()); + assert!(out.contains("package HolonModel")); + assert!(out.contains("}")); + // No block def nodes + assert_eq!(out.matches("block def").count(), 0); +} + +#[test] +fn test_28_sysml_v2_single_node() { + let h = Holon::root("alpha-id", "Alpha", HolonKind::SysmlBlock); + let g = CytoscapeGraph::from_holons(&[h]); + let out = SysmlV2Emitter::emit(&g); + assert!(out.contains("package HolonModel")); + assert!(out.contains("block def Alpha")); +} + +#[test] +fn test_29_owl2_turtle_empty() { + let out = Owl2Emitter::emit(&empty_graph()); + assert!(out.contains("@prefix owl:")); + assert!(out.contains("@prefix rdfs:")); + assert!(out.contains("owl:Ontology")); + // No class declarations for empty graph + assert!(!out.contains("a owl:Class")); +} + +#[test] +fn test_30_owl2_turtle_parent_child() { + let g = two_node_graph(); + let out = Owl2Emitter::emit(&g); + // Should have two class declarations + assert_eq!(out.matches("a owl:Class ;").count(), 2); + // Child should have rdfs:subClassOf parent + assert!(out.contains("holon:b rdfs:subClassOf holon:a")); +} + +// =========================================================================== +// Module 7: TypeRelationshipGraph (tests 31–34) +// =========================================================================== + +#[test] +fn test_31_type_graph_empty() { + let g = TypeRelationshipGraph::new(vec![], vec![]); + assert!(g.nodes.is_empty()); + assert!(g.relationships.is_empty()); + let cy = g.to_cytoscape(); + assert!(cy.nodes.is_empty()); + assert!(cy.edges.is_empty()); +} + +#[test] +fn test_32_seed_is_deterministic() { + let g1 = TypeRelationshipGraph::seed(); + let g2 = TypeRelationshipGraph::seed(); + assert!(g1.nodes.len() > 0); + assert!(g2.nodes.len() > 0); + // Seed is deterministic — same number of nodes and relationships + assert_eq!(g1.nodes.len(), g2.nodes.len()); + // First node should always be the same + assert_eq!(g1.nodes[0].id, g2.nodes[0].id); +} + +#[test] +fn test_33_dedup_identical_nodes() { + let n1 = TypeNode { + id: "dedup::MyType".to_string(), + label: "MyType".to_string(), + kind: "struct".to_string(), + parent_id: None, + z_layer: None, + semantic_type: None, + }; + let n2 = TypeNode { + id: "dedup::MyType".to_string(), + label: "MyType".to_string(), + kind: "struct".to_string(), + parent_id: None, + z_layer: None, + semantic_type: None, + }; + let r = TypeRelationship::new("dedup::MyType", "dedup::Other", TypeRelationshipKind::References); + // Duplicate relationship too + let r_dup = TypeRelationship::new("dedup::MyType", "dedup::Other", TypeRelationshipKind::References); + + let g = TypeRelationshipGraph::new(vec![n1, n2], vec![r, r_dup]); + assert_eq!(g.nodes.len(), 2); // Raw storage, no dedup + + let cy = g.to_cytoscape(); + assert_eq!(cy.nodes.len(), 1); // Deduped by ID + assert_eq!(cy.edges.len(), 1); // Deduped by (source, kind, target) +} + +#[test] +fn test_34_seed_to_cytoscape() { + let seed = TypeRelationshipGraph::seed(); + let cy = seed.to_cytoscape(); + assert!(cy.nodes.len() > 0); + assert!(cy.edges.len() > 0); + // Every node has an id and label + for n in &cy.nodes { + assert!(!n.data.id.is_empty()); + assert!(!n.data.label.is_empty()); + } + // Every edge connects existing nodes + let node_ids: std::collections::HashSet<&str> = + cy.nodes.iter().map(|n| n.data.id.as_str()).collect(); + for e in &cy.edges { + assert!(node_ids.contains(e.data.source.as_str())); + assert!(node_ids.contains(e.data.target.as_str())); + } +} + +// =========================================================================== +// Module 8: Observer (tests 35–37) +// =========================================================================== + +#[test] +fn test_35_observer_unavailable_graceful() { + // Point at non-existent script → cd fails → CDP_UNAVAILABLE + let tmp = tempfile::tempdir().expect("tempdir"); + let screenshot = tmp.path().join("shot.png"); + let script = tmp.path().join("no_such_script.py"); + let observer = VizObserver::new(screenshot, script); + let obs = observer.observe().expect("observe must never Err"); + assert_eq!(obs.raw_output, "CDP_UNAVAILABLE"); + assert_eq!(obs.node_count, 0); + assert_eq!(obs.edge_count, 0); + assert!(!obs.is_live()); +} + +#[test] +fn test_36_is_live_false_on_unavailable() { + let obs = VizObservation { + screenshot_path: Path::new("/tmp/fake.png").to_path_buf(), + node_count: 0, + edge_count: 0, + raw_output: "CDP_UNAVAILABLE".to_string(), + }; + assert!(!obs.is_live()); +} + +#[test] +fn test_37_is_live_true_on_real_output() { + let obs = VizObservation { + screenshot_path: Path::new("/tmp/real.png").to_path_buf(), + node_count: 5, + edge_count: 3, + raw_output: "ok\nnodes=5 edges=3\n".to_string(), + }; + assert!(obs.is_live()); +} + +// =========================================================================== +// Module 9: Integration — Full Pipeline (tests 38–40) +// =========================================================================== + +#[test] +fn test_38_process_to_holon_to_cytoscape_to_render_html() { + // 1. Process: register + authorize steps + let mut ctrl = ProcessController::new(); + for s in &["review", "approve", "sign-off"] { + ctrl.register_step(make_step(s)).unwrap(); + } + ctrl.authorize_step("review", "alice", 1000).unwrap(); + ctrl.authorize_step("approve", "bob", 2000).unwrap(); + ctrl.authorize_step("sign-off", "carol", 3000).unwrap(); + assert_eq!(ctrl.log().len(), 3); + + // 2. Build Holons representing the authorized steps + let holons: Vec = ctrl + .steps() + .iter() + .map(|s| Holon::root(s.step_id.clone(), s.description.clone(), HolonKind::ProcessNode)) + .collect(); + + // 3. Convert to CytoscapeGraph + let graph = CytoscapeGraph::from_holons(&holons); + assert_eq!(graph.nodes.len(), 3); + + // 4. Render to HTML + let html = HtmlRenderer::render(&graph); + assert!(html.contains("review")); + assert!(html.contains("approve")); + assert!(html.contains("sign-off")); + assert!(html.contains("Step review")); + assert!(html.contains("Step approve")); + assert!(html.contains("Step sign-off")); +} + +#[test] +fn test_39_branching_visualization() { + // Simulate a fork/merge process and verify Cytoscape edges cover both paths + let mut ctrl = ProcessController::new(); + for id in &["A", "B", "C", "D"] { + ctrl.register_step(make_step(id)).unwrap(); + } + + // Authorize along both fork paths + ctrl.authorize_step("A", "alice", 100).unwrap(); // A→B path + ctrl.authorize_step("B", "alice", 200).unwrap(); + ctrl.authorize_step("A", "bob", 300).unwrap(); // A→C path + ctrl.authorize_step("C", "bob", 400).unwrap(); + ctrl.authorize_step("D", "alice", 500).unwrap(); // merge point + ctrl.authorize_step("D", "bob", 600).unwrap(); + + // Build holons with edges modelling the fork/merge structure + + // Create Holons with children/edges that model: + // A → B → D (B authorized by alice, D by alice) + // A → C → D (C authorized by bob, D by bob) + let holons = vec![ + Holon { + id: "A".to_string(), + label: "Step A".to_string(), + kind: HolonKind::ProcessNode, + parent_id: None, + children: vec!["B".to_string(), "C".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "B".to_string(), + label: "Step B".to_string(), + kind: HolonKind::ProcessNode, + parent_id: Some("A".to_string()), + children: vec!["D".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "C".to_string(), + label: "Step C".to_string(), + kind: HolonKind::ProcessNode, + parent_id: Some("A".to_string()), + children: vec!["D".to_string()], + metadata: HashMap::new(), + }, + Holon { + id: "D".to_string(), + label: "Step D".to_string(), + kind: HolonKind::ProcessNode, + parent_id: Some("B".to_string()), // first parent — D could have multiple + children: vec![], + metadata: HashMap::new(), + }, + ]; + + let g = CytoscapeGraph::from_holons(&holons); + assert_eq!(g.nodes.len(), 4); + + // Verify both paths are present: A→B→D and A→C→D + let edge_ids: Vec<&str> = g.edges.iter().map(|e| e.data.id.as_str()).collect(); + assert!( + edge_ids.contains(&"A__contains__B"), + "missing A→B edge" + ); + assert!( + edge_ids.contains(&"A__contains__C"), + "missing A→C edge" + ); + assert!( + edge_ids.contains(&"B__contains__D"), + "missing B→D edge" + ); + assert!( + edge_ids.contains(&"C__contains__D"), + "missing C→D edge" + ); +} + +#[test] +fn test_40_cross_format_consistency() { + // Same graph → SysML-v2 + OWL2/Turtle + HTML → all contain same node count + let g = two_node_graph(); + assert_eq!(g.nodes.len(), 2); + + // SysML-v2 + let sysml = SysmlV2Emitter::emit(&g); + let sysml_block_count = sysml.matches("block def").count(); + assert_eq!( + sysml_block_count, 2, + "SysML-v2 should contain 2 block definitions, got {}", + sysml_block_count + ); + + // OWL2/Turtle + let owl = Owl2Emitter::emit(&g); + let class_count = owl.matches("a owl:Class ;").count(); + assert_eq!( + class_count, 2, + "OWL2 should contain 2 class declarations, got {}", + class_count + ); + + // HTML + let html = HtmlRenderer::render(&g); + assert!(html.contains("Alpha")); + assert!(html.contains("Beta")); + // HTML should contain both nodes in the JSON data + let alpha_pos = html.find("Alpha"); + let beta_pos = html.find("Beta"); + assert!(alpha_pos.is_some(), "Alpha missing from HTML"); + assert!(beta_pos.is_some(), "Beta missing from HTML"); +} + +// =========================================================================== +// Module 10: Error Boundary (tests 41–42) +// =========================================================================== + +#[test] +fn test_41_holon_error_display() { + let cases: Vec<(HolonError, &str)> = vec![ + (HolonError::NotFound("foo".into()), "holon not found: foo"), + ( + HolonError::EmptyAuthorizer, + "authorized_by must not be empty", + ), + (HolonError::EmptyStepId, "step_id must not be empty"), + ( + HolonError::DuplicateStep("bar".into()), + "step already registered: bar", + ), + (HolonError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "no file")), "io error:"), + (HolonError::Json(serde_json::from_str::("invalid").unwrap_err()), "json error:"), + (HolonError::Render("template fail".into()), "render error: template fail"), + ]; + + for (error, expected_prefix) in &cases { + let display = format!("{}", error); + assert!( + display.starts_with(expected_prefix), + "expected display of {:?} to start with '{}', got '{}'", + error, + expected_prefix, + display + ); + } +} + +#[test] +fn test_42_crate_re_exports() { + // Verify all public types are accessible from crate root by constructing + // minimal valid instances of each. + + // ProcessController + let _ctrl = ProcessController::new(); + + // ProcessStep + let _step = ProcessStep { + step_id: "s".into(), + description: "d".into(), + }; + + // TransitionReceipt — constructed via authorize (use a simplified mock) + // We just verify the type compiles by using the authorize method + let mut ctrl_for_receipt = ProcessController::new(); + ctrl_for_receipt.register_step(make_step("demo")).unwrap(); + let _receipt = ctrl_for_receipt + .authorize_step("demo", "tester", 100) + .unwrap(); + + // ImmutableActionLog + let _log = ImmutableActionLog::new(); + + // ActionKind + let _kind = ActionKind::StepAuthorized; + + // ActionRecord — constructed via append + let mut log_for_record = ImmutableActionLog::new(); + let _record = log_for_record.append(ActionKind::HolonCreated, "agent", 1, [0u8; 32]); + + // Holon + let _root = Holon::root("id", "label", HolonKind::SysmlBlock); + let _child = Holon::child("c", "child", HolonKind::OwlClass, "parent"); + + // HolonKind + let _hk = HolonKind::ProcessNode; + + // CytoscapeGraph + let _g = CytoscapeGraph { + nodes: vec![], + edges: vec![], + }; + + // CytoscapeNode / CytoscapeEdge + let _cy_node = CytoscapeNode { + data: holon_viz::CytoscapeNodeData { + id: "n".into(), + label: "N".into(), + kind: "struct".into(), + parent: None, + z_layer: None, + semantic_type: None, + }, + }; + let _cy_edge = CytoscapeEdge { + data: holon_viz::CytoscapeEdgeData { + id: "e".into(), + source: "a".into(), + target: "b".into(), + label: "knows".into(), + }, + }; + + // HtmlRenderer + let _html = HtmlRenderer::render(&_g); + + // SysmlV2Emitter + let _sysml = SysmlV2Emitter::emit(&_g); + + // Owl2Emitter + let _owl = Owl2Emitter::emit(&_g); + + // TypeRelationshipGraph + let _trg = TypeRelationshipGraph::new(vec![], vec![]); + let _seed = TypeRelationshipGraph::seed(); + let _cy_from_trg = _trg.to_cytoscape(); + + // TypeRelationshipKind + let _trk = TypeRelationshipKind::References; + + // VizObservation + let _obs = VizObservation { + screenshot_path: Path::new("/tmp/s.png").to_path_buf(), + node_count: 0, + edge_count: 0, + raw_output: "CDP_UNAVAILABLE".to_string(), + }; + + // VizObserver + let _observer = VizObserver::new( + Path::new("/tmp/s.png").to_path_buf(), + Path::new("/tmp/script.py").to_path_buf(), + ); + + // HolonError (variants exercised in test_41) + let _err_nf = HolonError::NotFound("x".into()); + let _err_empty_auth = HolonError::EmptyAuthorizer; + let _err_empty_step = HolonError::EmptyStepId; + let _render_err = HolonError::Render("oops".into()); + + // Confirm types are Debug (required for assert_eq! on error types) + let _ = format!("{:?}", _ctrl); + let _ = format!("{:?}", _kind); + let _ = format!("{:?}", _hk); + let _ = format!("{:?}", _trk); + let _ = format!("{:?}", _err_nf); + + // This test exists primarily for compilation verification — + // if all types compile and construct, re-exports are intact. + assert!(true, "all public types accessible from crate root"); +} diff --git a/crates/ledger-core/src/ledger_ops.rs b/crates/ledger-core/src/ledger_ops.rs index e506b3f..292403f 100644 --- a/crates/ledger-core/src/ledger_ops.rs +++ b/crates/ledger-core/src/ledger_ops.rs @@ -891,6 +891,18 @@ impl LedgerOperation for GenerateAuditTrailOp { } /// Check a tax deadline and emit an advisory issue if it is approaching. +/// +/// ## Current behavior +/// Returns `OperationResult::success` with 0 items processed. No calendar +/// lookup is performed yet — the `BusinessCalendar` field in `OperationContext` +/// is read but not queried. +/// +/// ## FutureScope +/// When `BusinessCalendar` integration is complete, this op will: +/// 1. Look up `self.deadline_id` in `ctx.calendar` +/// 2. Compute next due date via `BusinessCalendar::next_due` +/// 3. If `today + warn_days_before >= due_date` → emit an advisory issue +/// 4. Return result with issue text if approaching pub struct CheckTaxDeadlineOp { pub deadline_id: String, pub warn_days_before: u32, @@ -959,6 +971,7 @@ impl LedgerOperation for CheckTaxDeadlineOp { /// Ingest a PDF statement file via the external Python sidecar. /// +<<<<<<< Updated upstream /// Spawns the sidecar subprocess (`reqif-opa-mcp ingest --file --output ndjson`), /// reads NDJSON transaction candidates from stdout, runs the Rhai classification waterfall /// on each, and persists results to the workbook. Blake3 content-hash IDs ensure @@ -967,6 +980,23 @@ impl LedgerOperation for CheckTaxDeadlineOp { /// # Subprocess /// Requires `reqif-opa-mcp` on `PATH`. The intended long-term replacement is /// `docling convert ` once docling's NDJSON output shape is stabilised. +======= +/// ## Stability +/// **Future scope (phase-2).** This struct and its `LedgerOperation` implementation +/// are defined for forward architecture only. `execute()` returns +/// [`LedgerOpError::NotImplemented`]. The skeleton body documents the intended +/// logic for when implementation begins. +/// +/// ## FutureScope +/// Dependency: [reqif-opa-mcp](https://github.com/PromptExecution/reqif-opa-mcp) +/// (Python CLI). Implementation requires: +/// 1. Spawn subprocess: `reqif-opa-mcp ingest --file --output ndjson` +/// 2. Parse NDJSON stdout into `ReqIfCandidate` rows +/// 3. Classify each candidate through `RuleRegistry::classify_waterfall` +/// 4. Emit `ClassificationOutcome` rows via `ExportWorkbookOp` +/// 5. Handle subprocess failure via `LedgerOpError::ExternalProcessFailed` +/// 6. Idempotency via Blake3 content-hash dedup on re-ingest +>>>>>>> Stashed changes pub struct PdfIngestOp { pub input_path: PathBuf, pub rule_dir: PathBuf, @@ -979,7 +1009,11 @@ impl LedgerOperation for PdfIngestOp { } fn description(&self) -> &str { +<<<<<<< Updated upstream "Ingest a PDF statement file via the reqif-opa-mcp Python sidecar" +======= + "Ingest a PDF statement file via the reqif-opa-mcp Python sidecar (future/phase-2 — not yet implemented)" +>>>>>>> Stashed changes } fn is_idempotent(&self) -> bool { @@ -988,6 +1022,7 @@ impl LedgerOperation for PdfIngestOp { } fn execute(&self, _ctx: &OperationContext) -> Result { +<<<<<<< Updated upstream use crate::classify::ClassificationEngine; use crate::document::DocType; use crate::ingest::TransactionInput; @@ -1223,15 +1258,41 @@ impl LedgerOperation for PdfIngestOp { duration_ms: 0, row_errors, }) +======= + Err(LedgerOpError::NotImplemented( + "PdfIngestOp: not yet implemented (future/phase-2 — see FutureScope in doc comment)" + .to_string(), + )) +>>>>>>> Stashed changes } } /// Gate classified transactions through AGT compliance before workbook commit. /// +<<<<<<< Updated upstream /// Replaces OpaGateOp. Uses `LedgrrAgtGateway::compliance_report()` to determine /// whether transactions should proceed to the workbook or be flagged for review. #[cfg(feature = "cedar-policy")] pub struct CedarGateOp; +======= +/// ## Stability +/// **Future scope (phase-3).** This struct and its `LedgerOperation` implementation +/// are defined for forward architecture only. `execute()` returns +/// [`LedgerOpError::NotImplemented`]. The skeleton body documents the intended +/// logic for when implementation begins. +/// +/// ## FutureScope +/// Infrastructure prerequisite: OPA server (`opa run --server`). Requires: +/// 1. POST each `ClassificationOutcome` to `http://localhost:8181/v1/data/ledger/allow` +/// with body `{ "input": { "category": "...", "confidence": 0.9, "review": false } }` +/// 2. If OPA returns `{ "result": false }` → move tx to `FLAGS.open` with reason "opa_gate_rejected" +/// 3. If OPA is unreachable → `tracing::warn!` and fall through (do not hard-fail pipeline) +/// 4. OPA policy source in `opa/policies/ledger_classify.rego` +/// 5. Configurable policy bundle path via `OperationContext.opa_bundle_path` (already available) +pub struct OpaGateOp { + pub policy_bundle_path: Option, +} +>>>>>>> Stashed changes #[cfg(feature = "cedar-policy")] impl LedgerOperation for CedarGateOp { @@ -1240,6 +1301,7 @@ impl LedgerOperation for CedarGateOp { } fn description(&self) -> &str { +<<<<<<< Updated upstream "Gate classified transactions through AGT compliance before workbook commit" } @@ -1310,6 +1372,15 @@ impl LedgerOperation for CedarGateOp { Ok(OperationResult::success( "cedar-gate", ctx.classified_transactions.len(), +======= + "Run classified transactions through OPA policy gate before workbook commit (future/phase-3 — not yet implemented)" + } + + fn execute(&self, _ctx: &OperationContext) -> Result { + Err(LedgerOpError::NotImplemented( + "OpaGateOp: not yet implemented (future/phase-3 — see FutureScope in doc comment)" + .to_string(), +>>>>>>> Stashed changes )) } } diff --git a/crates/ledgerr-host/src/internal_openai.rs b/crates/ledgerr-host/src/internal_openai.rs index 0069f04..bf1f195 100644 --- a/crates/ledgerr-host/src/internal_openai.rs +++ b/crates/ledgerr-host/src/internal_openai.rs @@ -21,6 +21,15 @@ pub const INTERNAL_ROTEL_EXPORT_PLAN_URL: &str = "http://127.0.0.1:15115/rotel/e pub const INTERNAL_ROTEL_LOGS_URL: &str = "http://127.0.0.1:15115/v1/logs"; pub const INTERNAL_ROTEL_METRICS_URL: &str = "http://127.0.0.1:15115/v1/metrics"; pub const INTERNAL_ROTEL_TRACES_URL: &str = "http://127.0.0.1:15115/v1/traces"; + +/// Returns the rotel-visual base URL using the ROTEL_PORT env var (default 4318). +pub fn rotel_visual_base_url() -> String { + let port: u16 = std::env::var("ROTEL_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(4318u16); + format!("http://127.0.0.1:{port}") +} pub const INTERNAL_PHI_MODEL: &str = "phi-4-mini-reasoning"; pub const INTERNAL_LOCAL_API_KEY: &str = "local-tool-tray"; pub const DEFAULT_CLOUD_CHAT_URL: &str = "https://api.openai.com/v1/chat/completions"; @@ -467,10 +476,14 @@ pub fn docs_playbook_status() -> String { } pub fn internal_rotel_status() -> String { - let plan = RotelExportPlan::from_endpoint(&internal_rotel_endpoint(INTERNAL_OPENAI_ADDR)); + let base = rotel_visual_base_url(); + let plan = RotelExportPlan::from_endpoint(&RotelEndpoint { + otlp_http_endpoint: base.clone(), + ..RotelEndpoint::default() + }); [ - "rotel: embedded in internal OpenAI-compatible listener".to_string(), - format!("health_endpoint: {INTERNAL_ROTEL_HEALTH_URL}"), + format!("rotel: forwarded to rotel-visual at {base}"), + format!("health_endpoint: {base}/health"), format!("logs_endpoint: {}", plan.logs_url), format!("metrics_endpoint: {}", plan.metrics_url), format!("traces_endpoint: {}", plan.traces_url), @@ -920,9 +933,9 @@ fn route_http_request_for_addr( json_response(200, &chat_response(&request, assistant_text)) } -fn internal_rotel_endpoint(addr: &str) -> RotelEndpoint { +fn internal_rotel_endpoint(_addr: &str) -> RotelEndpoint { RotelEndpoint { - otlp_http_endpoint: format!("http://{addr}"), + otlp_http_endpoint: rotel_visual_base_url(), otlp_grpc_endpoint: "internal-listener-disabled".to_string(), arrow_connector_enabled: true, } @@ -960,30 +973,79 @@ fn otlp_signal_from_request_line(request_line: &str) -> Option { } fn rotel_otlp_response(signal: OTelSignal, body: &[u8]) -> String { - let payload = match serde_json::from_slice::(body) { - Ok(value) => value, + let payload: serde_json::Value = match serde_json::from_slice(body) { + Ok(payload) => payload, Err(error) => { return json_response( 400, - &serde_json::json!({ "error": format!("invalid OTLP JSON payload: {error}") }), + &serde_json::json!({ + "error": format!("invalid OTLP JSON payload: {error}") + }), ); } }; - let resource_count = otlp_resource_count(signal, &payload); - json_response( - 202, - &serde_json::json!({ - "accepted": true, - "service": "rotel-embedded", - "listener": "l3dg3rr-internal-openai", - "signal": signal.as_str(), - "content_type": "application/json", - "resource_count": resource_count, - "payload_bytes": body.len(), - "arrow_connector_enabled": true, - "classification_columns": ledger_core::observability::TelemetryArrowBatch::classification_columns(), - }), - ) + let base = rotel_visual_base_url(); + let path = match signal { + OTelSignal::Log => "/v1/logs", + OTelSignal::Metric => "/v1/metrics", + OTelSignal::Trace => "/v1/traces", + }; + let url = format!("{base}{path}"); + + match reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + { + Ok(client) => match client + .post(&url) + .header("Content-Type", "application/json") + .body(body.to_vec()) + .send() + { + Ok(resp) => { + let status = resp.status().as_u16(); + let resp_body = resp.text().unwrap_or_default(); + let reason = match status { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + _ => "", + }; + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + resp_body.len(), + resp_body + ) + } + Err(e) => { + // rotel-visual not reachable — return a fallback response + let payload = serde_json::json!({ + "accepted": true, + "service": "rotel-embedded-fallback", + "listener": "l3dg3rr-internal-openai", + "signal": signal.as_str(), + "content_type": "application/json", + "resource_count": otlp_resource_count(signal, &payload), + "payload_bytes": body.len(), + "arrow_connector_enabled": true, + "classification_columns": ledger_core::observability::TelemetryArrowBatch::classification_columns(), + "note": format!("rotel-visual unreachable at {url}: {e}"), + }); + json_response(202, &payload) + } + }, + Err(_) => { + // Fallback if reqwest client can't be built + json_response( + 202, + &serde_json::json!({ + "accepted": true, + "signal": signal.as_str(), + "note": "rotel-visual proxy unavailable" + }), + ) + } + } } fn otlp_resource_count(signal: OTelSignal, payload: &serde_json::Value) -> usize { @@ -1306,7 +1368,7 @@ mod tests { assert!(response.starts_with("HTTP/1.1 200 OK")); assert!(response.contains("\"service\":\"rotel-embedded\"")); assert!(response.contains("\"listener\":\"l3dg3rr-internal-openai\"")); - assert!(response.contains("\"logs\":\"http://127.0.0.1:15115/v1/logs\"")); + assert!(response.contains("\"logs\":\"http://127.0.0.1:4318/v1/logs\"")); assert!(response.contains("\"arrow_connector_enabled\":true")); } @@ -1317,9 +1379,9 @@ mod tests { let response = route_http_request(raw.as_bytes(), &FixedBackend, None); assert!(response.starts_with("HTTP/1.1 200 OK")); - assert!(response.contains("\"logs_url\":\"http://127.0.0.1:15115/v1/logs\"")); - assert!(response.contains("\"metrics_url\":\"http://127.0.0.1:15115/v1/metrics\"")); - assert!(response.contains("\"traces_url\":\"http://127.0.0.1:15115/v1/traces\"")); + assert!(response.contains("\"logs_url\":\"http://127.0.0.1:4318/v1/logs\"")); + assert!(response.contains("\"metrics_url\":\"http://127.0.0.1:4318/v1/metrics\"")); + assert!(response.contains("\"traces_url\":\"http://127.0.0.1:4318/v1/traces\"")); assert!(response.contains("\"abstract_regex_type\"")); } @@ -1331,9 +1393,9 @@ mod tests { route_http_request_for_addr(raw.as_bytes(), &FixedBackend, None, "127.0.0.1:18081"); assert!(response.starts_with("HTTP/1.1 200 OK")); - assert!(response.contains("\"logs_url\":\"http://127.0.0.1:18081/v1/logs\"")); - assert!(response.contains("\"metrics_url\":\"http://127.0.0.1:18081/v1/metrics\"")); - assert!(response.contains("\"traces_url\":\"http://127.0.0.1:18081/v1/traces\"")); + assert!(response.contains("\"logs_url\":\"http://127.0.0.1:4318/v1/logs\"")); + assert!(response.contains("\"metrics_url\":\"http://127.0.0.1:4318/v1/metrics\"")); + assert!(response.contains("\"traces_url\":\"http://127.0.0.1:4318/v1/traces\"")); } #[test] @@ -1370,7 +1432,7 @@ mod tests { let response = route_http_request(raw.as_bytes(), &FixedBackend, None); assert!(response.starts_with("HTTP/1.1 400 Bad Request")); - assert!(response.contains("invalid OTLP JSON payload")); + assert!(response.contains("\"error\":\"invalid OTLP JSON payload:")); } #[test] diff --git a/crates/ledgerr-llm/src/lib.rs b/crates/ledgerr-llm/src/lib.rs index f00a855..e0f43d4 100644 --- a/crates/ledgerr-llm/src/lib.rs +++ b/crates/ledgerr-llm/src/lib.rs @@ -23,7 +23,7 @@ const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; #[derive(Debug, Clone)] pub struct LlmConfig { pub api_key: String, - /// Model for vision/chat completions (default: gpt-4o). + /// Model for vision/chat completions (default: phi-4-mini-reasoning for local-first). pub model: String, /// Optional base URL override — enables local OpenAI-compatible endpoints /// (Ollama, LM Studio, future Gemma4 / Qwen3 adapters). @@ -35,7 +35,8 @@ impl LlmConfig { pub fn from_env() -> Self { Self { api_key: std::env::var("OPENAI_API_KEY").unwrap_or_default(), - model: std::env::var("LEDGERR_LLM_MODEL").unwrap_or_else(|_| "gpt-4o".into()), + model: std::env::var("LEDGERR_LLM_MODEL") + .unwrap_or_else(|_| "phi-4-mini-reasoning".into()), base_url: std::env::var("LEDGERR_LLM_BASE_URL").ok(), temperature: 0.0, } diff --git a/crates/ledgerr-mcp/src/focus_tool.rs b/crates/ledgerr-mcp/src/focus_tool.rs index c9befa4..50e09be 100644 --- a/crates/ledgerr-mcp/src/focus_tool.rs +++ b/crates/ledgerr-mcp/src/focus_tool.rs @@ -2,6 +2,13 @@ //! //! Actions: append_focus_record, query_focus_summary, compute_focus_delta, experiment_score. //! All actions accept JSON input and return JSON output via the MCP contract. +//! +//! # Persistence +//! Focus records are persisted to a JSON file so they survive MCP server restarts. +//! The file path is controlled by the `FOCUS_SIDECAR_PATH` env var (default: +//! `~/.local/share/b00t/focus/focus_records.json`). On startup, existing records +//! are loaded from this file. Every call to `handle_append` extends both the +//! in-memory store AND the file (atomically via tmp+rename). use ledgerr_focus::{ compute_focus_delta, format_focus_cli, ChargeCategory, ChargeFrequency, CostAndUsageRow, @@ -11,6 +18,9 @@ use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FocusToolInput { @@ -157,6 +167,113 @@ fn validate_focus_record(record: &FocusToolRecord) -> Result<(), String> { Ok(()) } +// --------------------------------------------------------------------------- +// Persistence — focus records are saved to a JSON file so they survive server +// restarts. On first access the file is loaded into a global `Mutex`-guarded +// Vec; every `handle_append` extends both the Vec and the file atomically. +// --------------------------------------------------------------------------- + +/// Resolve the storage path from `FOCUS_SIDECAR_PATH` env var, falling back +/// to `~/.local/share/b00t/focus/focus_records.json`. +fn focus_records_path() -> PathBuf { + let raw = std::env::var("FOCUS_SIDECAR_PATH") + .unwrap_or_else(|_| "~/.local/share/b00t/focus/focus_records.json".to_string()); + PathBuf::from(shellexpand::tilde(&raw).as_ref()) +} + +/// Global in-memory store. Loaded from disk on first use via +/// [`initialize_store`]; extended by [`handle_append`]. +static FOCUS_RECORDS: Mutex> = Mutex::new(Vec::new()); +static FOCUS_LOADED: AtomicBool = AtomicBool::new(false); + +/// Lazily populate the global store from the JSON file on disk. +/// Safe to call multiple times — only loads once. +fn initialize_store() { + if FOCUS_LOADED.load(Ordering::Acquire) { + return; + } + let mut guard = FOCUS_RECORDS.lock().expect("focus records lock poisoned"); + if !FOCUS_LOADED.load(Ordering::Acquire) { + let path = focus_records_path(); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => { + match serde_json::from_str::>(&content) { + Ok(records) => { + *guard = records; + } + Err(e) => { + tracing::warn!( + path = %path.display(), + err = %e, + "focus_records.json parse error — starting with empty store" + ); + } + } + } + Err(e) => { + tracing::warn!( + path = %path.display(), + err = %e, + "focus_records.json read error — starting with empty store" + ); + } + } + } + FOCUS_LOADED.store(true, Ordering::Release); + } +} + +/// Atomically write the full record list to the JSON file. +/// Uses a temporary sibling file + rename so a crash mid-write never +/// leaves a truncated sidecar. +fn save_records(records: &[FocusToolRecord]) { + let path = focus_records_path(); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::warn!( + path = %parent.display(), + err = %e, + "failed to create focus records directory" + ); + return; + } + } + let json = match serde_json::to_string_pretty(records) { + Ok(j) => j, + Err(e) => { + tracing::warn!(err = %e, "focus records serialization failed"); + return; + } + }; + let tmp_path = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp_path, &json) { + tracing::warn!( + path = %tmp_path.display(), + err = %e, + "focus records temp write failed" + ); + return; + } + if let Err(e) = std::fs::rename(&tmp_path, &path) { + tracing::warn!( + from = %tmp_path.display(), + to = %path.display(), + err = %e, + "focus records rename failed" + ); + } +} + +/// Reset the global store (test helper). Only available in `#[cfg(test)]`. +#[cfg(test)] +fn reset_store_for_test() { + if let Ok(mut guard) = FOCUS_RECORDS.lock() { + guard.clear(); + } + FOCUS_LOADED.store(false, Ordering::Release); +} + fn handle_append(input: FocusToolInput) -> Result { // Validate all incoming records against the FOCUS v1.3 schema before processing for record in &input.records { @@ -175,10 +292,20 @@ fn handle_append(input: FocusToolInput) -> Result { .collect::>() .join("\n"); - let summary = input - .experiment_id - .as_ref() - .map(|eid| format!("FOCUS {FOCUS_SPEC_VERSION}: {n} rows appended to experiment {eid}", n = rows.len())); + // Persist: extend the in-memory store AND write to the JSON file so records + // survive MCP server restarts. + initialize_store(); + if let Ok(mut guard) = FOCUS_RECORDS.lock() { + guard.extend(input.records.clone()); + save_records(&guard); + } + + let summary = input.experiment_id.as_ref().map(|eid| { + format!( + "FOCUS {FOCUS_SPEC_VERSION}: {n} rows appended to experiment {eid}", + n = rows.len() + ) + }); Ok(FocusToolOutput { spec_version: FOCUS_SPEC_VERSION, @@ -191,6 +318,17 @@ fn handle_append(input: FocusToolInput) -> Result { } fn handle_query_summary(_input: FocusToolInput) -> Result { + // Read from the persisted store to reflect all previously appended records. + initialize_store(); + let (count, total_cost) = match FOCUS_RECORDS.lock() { + Ok(guard) => { + let c = guard.len(); + let total: f64 = guard.iter().map(|r| r.billed_cost).sum(); + (c, total) + } + Err(_) => (0, 0.0), + }; + Ok(FocusToolOutput { spec_version: FOCUS_SPEC_VERSION, action: "query_focus_summary".into(), @@ -198,7 +336,7 @@ fn handle_query_summary(_input: FocusToolInput) -> Result Vec { pub fn handle_focus_tool(arguments: &Value) -> Value { use crate::contract::parse_focus; use crate::focus_tool::{self, FocusToolInput, FocusToolRecord}; - use std::io::Write; let request = match parse_focus(arguments) { Ok(r) => r, @@ -133,48 +132,15 @@ pub fn handle_focus_tool(arguments: &Value) -> Value { }; let input = FocusToolInput { action: "append_focus_record".into(), - records: vec![record.clone()], + records: vec![record], experiment_id: experiment_id.clone(), personality: None, }; match focus_tool::handle_focus_tool(input) { - Ok(output) => { - // Persist the appended record (not the tool output) to JSONL. - // Path defaults to temp dir; override with FOCUS_SIDECAR_PATH env var. - // TODO: derive path from manifest/workbook path when available in context. - let sidecar_path = std::env::var("FOCUS_SIDECAR_PATH") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::env::temp_dir().join("focus_records.jsonl")); - match serde_json::to_string(&record) { - Ok(serialized) => { - match std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&sidecar_path) - { - Ok(mut f) => { - if let Err(e) = writeln!(f, "{serialized}") { - tracing::warn!( - path = %sidecar_path.display(), - err = %e, - "focus_records JSONL write failed" - ); - } - } - Err(e) => tracing::warn!( - path = %sidecar_path.display(), - err = %e, - "focus_records JSONL open failed" - ), - } - } - Err(e) => tracing::warn!(err = %e, "focus record serialization failed"), - } - json!({ - "content": [text_content(json!(output))], - "isError": false - }) - } + Ok(output) => json!({ + "content": [text_content(json!(output))], + "isError": false + }), Err(err) => error_envelope(&ToolError::Internal(err)), } } diff --git a/crates/rotel-visual/src/lib.rs b/crates/rotel-visual/src/lib.rs index 506b2b0..466eddd 100644 --- a/crates/rotel-visual/src/lib.rs +++ b/crates/rotel-visual/src/lib.rs @@ -695,10 +695,62 @@ pub fn create_app() -> Result { } pub async fn run_server() -> Result<(), anyhow::Error> { - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") + let port: u16 = std::env::var("ROTEL_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(4318u16); + + // --- Pre-bind port collision check --- + let check_addr = format!("127.0.0.1:{port}"); + match std::net::TcpStream::connect_timeout( + &check_addr.parse::().unwrap(), + std::time::Duration::from_millis(200), + ) { + Ok(_) => { + eprintln!( + "WARN: Port {port} is already in use by another process. \ + rotel-visual may conflict with the existing listener." + ); + // Optionally try to identify the owner via /proc/net/tcp (Linux) + #[cfg(target_os = "linux")] + { + if let Ok(contents) = std::fs::read_to_string("/proc/net/tcp") { + for line in contents.lines().skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 10 { + // local_address is in hex colon format: 00000000:10DE + if let Some(port_hex) = parts[1].split(':').nth(1) { + if u16::from_str_radix(port_hex, 16).ok() == Some(port) { + if let Ok(pid) = parts[9].trim_end_matches(':').parse::() { + if pid > 0 { + eprintln!( + " -> PID {pid} is listening on port {port}. \ + Run: ps -p {pid} -o comm= to identify the process." + ); + } + } + } + } + } + } + } + } + } + Err(_) => { + // Port is free — good to proceed + } + } + + let addr = format!("0.0.0.0:{port}"); + let listener = tokio::net::TcpListener::bind(&addr) .await - .map_err(|e| anyhow::anyhow!("Failed to bind to 0.0.0.0:8080: {e}"))?; - info!("Rotel Visual OTel Surface starting on 0.0.0.0:8080"); + .map_err(|e| { + anyhow::anyhow!( + "Failed to bind to {addr}: {e}. \ + Port may be in use. Set ROTEL_PORT env var to use a different port." + ) + })?; + info!("Rotel Visual OTel Surface starting on {addr}"); axum::serve(listener, create_app()?) .await diff --git a/crates/rotel-visual/tests/health_dashboard_tests.rs b/crates/rotel-visual/tests/health_dashboard_tests.rs index 5ef4a4f..cd53135 100644 --- a/crates/rotel-visual/tests/health_dashboard_tests.rs +++ b/crates/rotel-visual/tests/health_dashboard_tests.rs @@ -9,9 +9,9 @@ fn test_app() -> axum::Router { #[tokio::test] async fn test_health_endpoint() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); - let response = app + let response = _app .oneshot( Request::builder() .uri("/health") @@ -26,9 +26,9 @@ async fn test_health_endpoint() { #[tokio::test] async fn test_dashboard_endpoint() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); - let response = app + let response = _app .oneshot( Request::builder() .uri("/") @@ -50,7 +50,7 @@ async fn test_dashboard_endpoint() { #[tokio::test] async fn test_otlp_logs_ingestion_accepts_json_and_returns_202() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); let body = json!({ "resourceLogs": [ @@ -78,7 +78,7 @@ async fn test_otlp_logs_ingestion_accepts_json_and_returns_202() { #[tokio::test] async fn test_otlp_metrics_ingestion_accepts_json_and_returns_202() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); let body = json!({ "resourceMetrics": [ @@ -106,7 +106,7 @@ async fn test_otlp_metrics_ingestion_accepts_json_and_returns_202() { #[tokio::test] async fn test_otlp_traces_ingestion_accepts_json_and_returns_202() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); let body = json!({ "resourceSpans": [ @@ -134,9 +134,9 @@ async fn test_otlp_traces_ingestion_accepts_json_and_returns_202() { #[tokio::test] async fn test_otlp_logs_rejects_invalid_json_with_400() { - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); - let response = app + let response = _app .oneshot( Request::builder() .uri("/v1/logs") @@ -202,7 +202,7 @@ async fn test_classified_artifacts_are_accepted_via_otlp_logs() { async fn test_ring_buffer_populated_after_otlp_log_ingest() { // Verify that ingesting OTLP logs populates the ring buffer (returns 202). // Ring-buffer replay to new WebSocket subscribers requires a live server. - let app = rotel_visual::create_app().expect("create_app failed"); + let _app = rotel_visual::create_app().expect("create_app failed"); // Ingest a log to populate the ring buffer let body = json!({ @@ -282,7 +282,7 @@ async fn test_metrics_endpoint_increments_after_ingestion() { .await .unwrap(); let baseline_json: serde_json::Value = serde_json::from_slice(&baseline_body).unwrap(); - let baseline_logs = baseline_json["logs_ingested_total"].as_u64().unwrap(); + let baseline_metrics = baseline_json["metrics_ingested_total"].as_u64().unwrap(); // Ingest a metric — must include a named metric so the counter increments let body = json!({ @@ -291,7 +291,7 @@ async fn test_metrics_endpoint_increments_after_ingestion() { "resource": { "attributes": [] }, "scopeMetrics": [ { - "metrics": [{ "name": "test_counter" }] + "metrics": [{ "name": "test_metric_1" }] } ] } @@ -325,9 +325,9 @@ async fn test_metrics_endpoint_increments_after_ingestion() { .await .unwrap(); let after_json: serde_json::Value = serde_json::from_slice(&after_body).unwrap(); - let after_logs = after_json["metrics_ingested_total"].as_u64().unwrap(); + let after_metrics = after_json["metrics_ingested_total"].as_u64().unwrap(); - assert_eq!(after_logs, baseline_logs + 1); + assert_eq!(after_metrics, baseline_metrics + 1); } #[tokio::test] diff --git a/docs/rotel-otel-journal-surface.md b/docs/rotel-otel-journal-surface.md index aefabba..173189e 100644 --- a/docs/rotel-otel-journal-surface.md +++ b/docs/rotel-otel-journal-surface.md @@ -1,10 +1,18 @@ # Rotel OTel Journal Surface l3dg3rr owns typed telemetry semantics before data reaches a collector. -Rotel is the embedded OpenTelemetry collector boundary; the Rust core models +Rotel is the OpenTelemetry collector boundary; the Rust core models OTel object-shape polyfills, classifies log shapes, and emits deterministic journal artifacts that justify metric triggers. +There are two layers: + +1. **`rotel-visual` service** (port `ROTEL_PORT`, default `4318`) — the real + OTLP ingestion, log classification, ring buffer, and WebSocket dashboard. +2. **Host proxy** (port `15115`) — the ledgerr-host internal gateway forwards + OTLP signal requests to `rotel-visual` for real classification instead of + stubbing. + ## Contract - `OTelLogRecord`, `OTelMetric`, and `OTelSpan` polyfill the log, metric, and @@ -20,20 +28,34 @@ journal artifacts that justify metric triggers. ## Internal Listener -The l3dg3rr host service exposes the embedded Rotel surface on the same -internal OpenAI-compatible model gateway listener: +The ledgerr-host internal gateway (port `15115`) proxies OTLP signal requests +to the `rotel-visual` service (port `ROTEL_PORT`, default `4318`): ```text GET http://127.0.0.1:15115/rotel/health GET http://127.0.0.1:15115/rotel/export-plan -POST http://127.0.0.1:15115/v1/logs -POST http://127.0.0.1:15115/v1/metrics -POST http://127.0.0.1:15115/v1/traces +POST http://127.0.0.1:15115/v1/logs → forwarded to rotel-visual +POST http://127.0.0.1:15115/v1/metrics → forwarded to rotel-visual +POST http://127.0.0.1:15115/v1/traces → forwarded to rotel-visual +``` + +The rotel-visual service also exposes its own endpoints directly: + +```text +GET http://127.0.0.1:{ROTEL_PORT}/health +GET http://127.0.0.1:{ROTEL_PORT}/metrics +GET http://127.0.0.1:{ROTEL_PORT}/ +WS ws://127.0.0.1:{ROTEL_PORT}/ws/telemetry +POST http://127.0.0.1:{ROTEL_PORT}/v1/logs +POST http://127.0.0.1:{ROTEL_PORT}/v1/metrics +POST http://127.0.0.1:{ROTEL_PORT}/v1/traces +POST http://127.0.0.1:{ROTEL_PORT}/rotel/evaluate ``` -The `/v1/*` OTLP paths accept JSON payloads and return an explicit -`rotel-embedded` acceptance artifact. The gateway still owns OpenAI chat at -`/v1/chat/completions`; Rotel hosting is additive on the internal listener. +The `/v1/*` OTLP paths accept JSON payloads and return the rotel-visual +classification artifact. The gateway still owns OpenAI chat at +`/v1/chat/completions` on port `15115`; Rotel proxying is additive on the +internal listener. ## Example Rule diff --git a/ledgrrr.just b/ledgrrr.just index 5762b5f..6dcffb5 100644 --- a/ledgrrr.just +++ b/ledgrrr.just @@ -1,16 +1,23 @@ -# ── ledgrrr — ledgerr-mcp lifecycle (just module example) ───────────────── +# ── ledgrrr — ledgerr-mcp lifecycle + viz dashboard + test harness ──────── +# Just module — shared across vendor/l3dg3rr (symlink: vendor/ledgrrr). # -# Just module syntax: +# Module pattern (canonical): # In parent justfile: mod ledgrrr 'vendor/ledgrrr/ledgrrr.just' -# Invocation: just ledgrrr build +# Invocation: just ledgrrr # List recipes: just --list ledgrrr # -# Module scope: -# - Recipes and variables are scoped to the module -# - Recipes run in the module's source directory -# - Environment variables loaded only in root justfile +# When to use a module vs inline: +# - VENDOR-SCOPED recipes → module in vendor//.just +# e.g. vendor/ledgrrr/ledgrrr.just, vendor/irontology-mcp/irontology.just +# - ROOT-SCOPED recipes with cross-cutting concerns → inline in justfile +# e.g. publish-dry-run, ansible-k0s +# - SUBSYSTEM lifecycle (build/test/start/stop/viz) → module # -# symlink: vendor/ledgrrr -> vendor/l3dg3rr (polyseme mapping) +# Rationale: +# - Prevents justfile bloat (b00t justfile was 1169 lines) +# - Recipes run in module's source directory automatically +# - Module recipes are namespaced: `just ledgrrr viz` not `just ledgrrr-viz` +# - Vendors own their justfiles; root justfile only adds `mod ` line # # Reference: https://just.systems/man/en/modules.html @@ -53,5 +60,47 @@ docker-logs: # Build native binary and copy to ~/.cargo/bin install: build - cp target/release/ledgerr-mcp-server {{env_var_or_default("HOME", "~")}}/.cargo/bin/ledgerr-mcp - echo "installed" + cp target/release/ledgerr-mcp-server {{env_var_or_default("HOME", "~")}}/.local/bin/ledgerr-mcp-server + echo "installed to ~/.local/bin/ledgerr-mcp-server" + +# ─── Viz Dashboard Server ────────────────────────────────────────────────────── + +# Start HTTP + CDP-compatible viz dashboard (default :8080) +viz PORT="8080": + python3 scripts/ledgrrr-viz-serve.py start --port={{PORT}} + +# Stop the viz dashboard server +viz-stop: + python3 scripts/ledgrrr-viz-serve.py stop + +# Show viz dashboard server status +viz-status: + python3 scripts/ledgrrr-viz-serve.py status + +# Regenerate dashboard HTML from the holon-viz demo binary +viz-build: + cargo run -p holon-viz --bin holon-viz-demo + +# ─── Test Harness ────────────────────────────────────────────────────────────── + +# Run comprehensive MECE test suite (42 tests) +test: + cargo test --test holon_viz_comprehensive -p holon-viz + +# Run ALL holon-viz tests (unit + integration) +test-all: + cargo test -p holon-viz + +# ─── Lifecycle ───────────────────────────────────────────────────────────────── + +# Show subsystem status (repo, binary, running) +status: + @printf "Ledgrrr subsystem status:\n" + @if [ -d .git ]; then printf " ✅ Repo: %s\n" "$$(pwd)"; else printf " ❌ Repo missing\n"; fi + @if [ -f target/release/ledgerr-mcp-server ]; then \ + ver=$$(strings target/release/ledgerr-mcp-server 2>/dev/null | grep -E '^\d+\.\d+\.\d+' | head -1 || echo "built"); \ + printf " ✅ Binary: %s (%s)\n" "target/release/ledgerr-mcp-server" "$$ver"; \ + else printf " ❌ Binary not built (run: just ledgrrr build)\n"; fi + @if pidof ledgerr-mcp-server >/dev/null 2>&1; then \ + printf " ✅ Running (pid %s)\n" "$$(pidof ledgerr-mcp-server)"; \ + else printf " ○ Not running\n"; fi diff --git a/scripts/ledgrrr-viz-serve.py b/scripts/ledgrrr-viz-serve.py new file mode 100755 index 0000000..17d4d2d --- /dev/null +++ b/scripts/ledgrrr-viz-serve.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""ledgrrr-viz-serve — lightweight HTTP server for ledgrrr visualization dashboard. + +Usage: + python3 ledgrrr-viz-serve.py [--port 8080] [--cdp-port 19222] + +Serves the Cytoscape.js dashboard on HTTP and exposes a minimal CDP-compatible +JSON endpoint at /json/version and /json/list for VizObserver compatibility. + +Hermes management: + python3 ledgrrr-viz-serve.py start -- daemonize, write pidfile + python3 ledgrrr-viz-serve.py stop -- kill by pidfile + python3 ledgrrr-viz-serve.py status -- check if running +""" + +import http.server +import json +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + +# ── paths ────────────────────────────────────────────────────────────────── +SCRIPT_DIR = Path(__file__).parent.resolve() +VENDOR_DIR = SCRIPT_DIR.parent +CRATE_DIR = VENDOR_DIR / "crates" / "holon-viz" +TARGET_DIR = CRATE_DIR / "target" +DASHBOARD_HTML = TARGET_DIR / "ledgrrr-viz-dashboard.html" +PIDFILE = Path("/tmp/ledgrrr-viz-serve.pid") +DEFAULT_PORT = 8080 +CDP_PORT = 19222 + +# ═══════════════════════════════════════════════════════════════════════════ +# Minimal CDP-compatible endpoints +# ═══════════════════════════════════════════════════════════════════════════ + +CDP_VERSION_RESPONSE = { + "Browser": "ledgrrr-viz-serve/0.8.0", + "Protocol-Version": "1.3", + "User-Agent": "ledgrrr-viz-serve", + "V8-Version": "0.0.0", + "WebKit-Version": "0.0.0", + "webSocketDebuggerUrl": f"ws://localhost:{CDP_PORT}/devtools/page/ledgrrr-1", +} + +CDP_LIST_RESPONSE = [ + { + "id": "ledgrrr-1", + "title": "ledgrrr Viz Dashboard", + "url": f"http://localhost:{DEFAULT_PORT}/", + "webSocketDebuggerUrl": f"ws://localhost:{CDP_PORT}/devtools/page/ledgrrr-1", + "devtoolsFrontendUrl": f"devtools://devtools/bundled/js_app.html?ws=localhost:{CDP_PORT}/devtools/page/ledgrrr-1", + "type": "page", + } +] + + +class LedgrrrHTTPHandler(http.server.SimpleHTTPRequestHandler): + """Serves the dashboard HTML and CDP JSON endpoints.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(DASHBOARD_HTML.parent), **kwargs) + + def do_GET(self): + path = self.path.rstrip("/") + + # CDP-compatible endpoints (VizObserver integration) + if path == "/json/version": + self._json_response(CDP_VERSION_RESPONSE) + return + if path == "/json/list": + self._json_response(CDP_LIST_RESPONSE) + return + if path == "/json": + self._json_response(CDP_LIST_RESPONSE) + return + + # Serve the dashboard HTML at / + if path == "" or path == "/": + self._serve_dashboard() + return + + # Serve static files from the target directory + super().do_GET() + + def _json_response(self, data): + body = json.dumps(data, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _serve_dashboard(self): + if DASHBOARD_HTML.exists(): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + with open(DASHBOARD_HTML, "rb") as f: + self.wfile.write(f.read()) + else: + self.send_response(404) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write( + b"Dashboard not found. Run: cd vendor/ledgrrr && " + b"cargo run -p holon-viz --bin holon-viz-demo\n" + ) + + def log_message(self, format, *args): + """Quiet logging — only log non-static requests.""" + if self.path not in ("/json/version", "/json/list", "/json"): + super().log_message(format, *args) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Server management +# ═══════════════════════════════════════════════════════════════════════════ + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def cmd_start(port: int, cdp_port: int, foreground: bool): + if PIDFILE.exists(): + try: + pid = int(PIDFILE.read_text().strip()) + os.kill(pid, 0) + print(f"ledgrrr-viz-serve already running (pid {pid}) on :{port}") + return + except (ProcessLookupError, ValueError): + PIDFILE.unlink(missing_ok=True) + + # Find free ports if requested + if port == 0: + port = find_free_port() + if cdp_port == 0: + cdp_port = find_free_port() + + # Build the CDP-compatible response dynamically + global CDP_VERSION_RESPONSE, CDP_LIST_RESPONSE + CDP_VERSION_RESPONSE["webSocketDebuggerUrl"] = f"ws://localhost:{cdp_port}/devtools/page/ledgrrr-1" + CDP_LIST_RESPONSE[0]["url"] = f"http://localhost:{port}/" + CDP_LIST_RESPONSE[0]["webSocketDebuggerUrl"] = f"ws://localhost:{cdp_port}/devtools/page/ledgrrr-1" + CDP_LIST_RESPONSE[0]["devtoolsFrontendUrl"] = ( + f"devtools://devtools/bundled/js_app.html?ws=localhost:{cdp_port}/devtools/page/ledgrrr-1" + ) + + if foreground: + _run_server(port) + return + + # Daemonize + pid = os.fork() + if pid > 0: + # Parent + PIDFILE.write_text(str(pid)) + print(f"ledgrrr-viz-serve started (pid {pid}) on http://localhost:{port}") + print(f" CDP endpoint: http://localhost:{port}/json/version") + print(f" Dashboard: http://localhost:{port}/") + return + + # Child (daemon) + os.setsid() + _run_server(port) + + +def _run_server(port: int): + server = http.server.HTTPServer(("0.0.0.0", port), LedgrrrHTTPHandler) + print(f"Serving ledgrrr viz dashboard on http://localhost:{port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +def cmd_stop(): + if not PIDFILE.exists(): + print("ledgrrr-viz-serve is not running") + return + try: + pid = int(PIDFILE.read_text().strip()) + os.kill(pid, signal.SIGTERM) + time.sleep(0.5) + # Force kill if still alive + try: + os.kill(pid, 0) + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + PIDFILE.unlink(missing_ok=True) + print(f"ledgrrr-viz-serve (pid {pid}) stopped") + except (ProcessLookupError, ValueError): + PIDFILE.unlink(missing_ok=True) + print("ledgrrr-viz-serve was not running (stale pidfile cleaned)") + + +def cmd_status(): + if not PIDFILE.exists(): + print("ledgrrr-viz-serve: STOPPED") + return + try: + pid = int(PIDFILE.read_text().strip()) + os.kill(pid, 0) + print(f"ledgrrr-viz-serve: RUNNING (pid {pid})") + except (ProcessLookupError, ValueError): + PIDFILE.unlink(missing_ok=True) + print("ledgrrr-viz-serve: STOPPED (stale pidfile cleaned)") + + +def cmd_regenerate(): + """Regenerate the dashboard HTML from the holon-viz crate.""" + vendor_dir = Path(__file__).parent + print("Rebuilding dashboard...") + result = subprocess.run( + ["cargo", "run", "-p", "holon-viz", "--bin", "holon-viz-demo"], + cwd=vendor_dir, + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode == 0: + print("Dashboard regenerated.") + else: + print(f"Build failed:\n{result.stderr}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# CLI +# ═══════════════════════════════════════════════════════════════════════════ + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return + + cmd = sys.argv[1] + port = DEFAULT_PORT + cdp_port_val = CDP_PORT + foreground = False + + for arg in sys.argv[2:]: + if arg == "--foreground": + foreground = True + elif arg.startswith("--port="): + port = int(arg.split("=", 1)[1]) + elif arg.startswith("--cdp-port="): + cdp_port_val = int(arg.split("=", 1)[1]) + + if cmd == "start": + cmd_start(port, cdp_port_val, foreground) + elif cmd == "stop": + cmd_stop() + elif cmd == "status": + cmd_status() + elif cmd == "regenerate": + cmd_regenerate() + elif cmd == "--help" or cmd == "-h": + print(__doc__) + else: + print(f"Unknown command: {cmd}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/server.json b/server.json index 0ebba4d..d94373f 100644 --- a/server.json +++ b/server.json @@ -18,7 +18,7 @@ }, "environmentVariables": [ { - "name": "LEDGER_WORKBOOK_PATH", + "name": "LEDGERR_WORKBOOK_PATH", "description": "Path to the Excel workbook (default: tax-ledger.xlsx in CWD)", "isRequired": false, "format": "string", diff --git a/skills/ledgerr-devops/SKILL.md b/skills/ledgerr-devops/SKILL.md index 24ac494..9b05639 100644 --- a/skills/ledgerr-devops/SKILL.md +++ b/skills/ledgerr-devops/SKILL.md @@ -66,7 +66,7 @@ just mcp-podman-run main # pull + run, mount $PWD/data:/data The container runs `/usr/local/bin/ledgerr-mcp-server` (stdio MCP transport). Mount `-v $PWD/data:/data` for workbook and PDF inbox. -Env vars: `LEDGER_WORKBOOK_PATH`, `LEDGER_PDF_INBOX`. +Env vars: `LEDGERR_WORKBOOK_PATH`, `LEDGER_PDF_INBOX`. ## Secrets Required (CI)