diff --git a/scenarios/crm-helm/Cargo.toml b/scenarios/crm-helm/Cargo.toml index 279ca50..2443732 100644 --- a/scenarios/crm-helm/Cargo.toml +++ b/scenarios/crm-helm/Cargo.toml @@ -1,16 +1,21 @@ [package] -name = "example-crm-helm-showcase" +name = "scenario-crm-helm" version = "0.0.0" edition = "2024" publish = false +description = "Headless CRM Helm scenario: 7 gRPC modules assembled over an in-memory kernel store, driven without TCP or RunwayAppHost" + +[lib] +name = "scenario_crm_helm" +path = "src/lib.rs" [[bin]] -name = "crm-helm-showcase" +name = "crm-helm" path = "src/main.rs" [dependencies] -anyhow.workspace = true -async-trait.workspace = true +anyhow = "1" +async-trait = "0.1" chrono.workspace = true serde.workspace = true serde_json.workspace = true @@ -28,13 +33,19 @@ prost-types = "0.13" # Axum — not in workspace deps axum = "0.8" +# Tower for in-process request routing (oneshot) +tower = { version = "0.5", features = ["util"] } +http = "1" +http-body-util = "0.1" + # Cross-repo: Helms domain crates (no converge-* deps) application-kernel = { path = "../../../bedrock-platform/helms/crates/application-kernel" } application-storage = { path = "../../../bedrock-platform/helms/crates/application-storage" } -# Cross-repo: Runtime Runway app host -runway-app-host = { path = "../../../runtime-runway/crates/runway-app-host" } -runway-storage = { path = "../../../runtime-runway/crates/runway-storage" } +# Cross-repo: Helms contracts — HelmModule trait + substrate (memory + sse) +# RP-HELMS-SUBSTRATE-SEAM (RFL-171 T7: headless atelier copy — no runway deps) +helm-module-contracts = { path = "../../../bedrock-platform/helms/contracts/crates/helm-module-contracts" } +helm-event-substrate = { path = "../../../bedrock-platform/helms/contracts/crates/helm-event-substrate", features = ["memory", "sse"] } [build-dependencies] tonic-build = "0.12" diff --git a/scenarios/crm-helm/README.md b/scenarios/crm-helm/README.md new file mode 100644 index 0000000..60a148c --- /dev/null +++ b/scenarios/crm-helm/README.md @@ -0,0 +1,40 @@ +# crm-helm — headless CRM composition over the in-memory substrate + +Repatriated from `helms/showcase/` (RFL-171 Seam A): this scenario now depends +only on Bedrock-owned contracts — `helm-module-contracts` (module mounting) and +`helm-event-substrate` (event hub, leases; `memory` + `sse` features) — plus the +in-memory `AppKernelStore`. No `runway-*` dependency (`cargo tree | grep runway` +is empty by design). + +## What it demonstrates + +Seven CRM capability modules (parties, documents, facts, conversations, +opportunities, workflow, metadata) assembled into one axum `Router` via the +`HelmModule` contract, driven headless with `tower::ServiceExt::oneshot` — +no TCP bind, no `RunwayAppHost`, no `StorageKit`. Output is JSONL events in +the same style as the other `helm-*-headless` scenarios. + +## Honest state (assembly.complete event reports it) + +- `hub_consumers: 0 / lease_consumers: 0` — the `EventHub` and + `InMemoryLeaseStore` are allocated and injectable, but no module consumes + them yet. +- The 7 mounted routers are status surfaces; the real gRPC service structs + (e.g. `PartiesGrpc`) are not wired into the mounted axum surface yet. +- Both gaps are tracked as RFL-155 stress-gate scope: this scenario graduates + when consumer counts are > 0 and real services answer through the mount. + +## The deployed variant + +The runway-backed composition (RunwayAppHost + StorageKit + serve) lives in +the helms repo at `apps/crm-helm` — app-platform territory. This scenario and +that app share the same module code; they differ only in the injected +substrate implementations (in-memory here, runway there). That symmetry is +the point of the seam. + +## Run + +```bash +cargo run -p scenario-crm-helm # JSONL to stdout, terminates +cargo test -p scenario-crm-helm # router + lease integration tests +``` diff --git a/scenarios/crm-helm/src/conversations.rs b/scenarios/crm-helm/src/conversations.rs index 226cba7..da27e6e 100644 --- a/scenarios/crm-helm/src/conversations.rs +++ b/scenarios/crm-helm/src/conversations.rs @@ -5,7 +5,7 @@ use application_kernel::{ActivityAppend, CommunicationRecord}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; @@ -156,4 +156,14 @@ impl HelmModule for ConversationsModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/conversations/status", + get(|| async { + Json(serde_json::json!({ "module": "crm.conversations", "status": "ok" })) + }), + ) + } } diff --git a/scenarios/crm-helm/src/documents.rs b/scenarios/crm-helm/src/documents.rs index 51ebd61..eff7fbe 100644 --- a/scenarios/crm-helm/src/documents.rs +++ b/scenarios/crm-helm/src/documents.rs @@ -5,7 +5,7 @@ use application_kernel::{DocumentAttach, NoteAppend}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, documents as documents_pb}; @@ -116,4 +116,14 @@ impl HelmModule for DocumentsModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/documents/status", + get(|| async { + Json(serde_json::json!({ "module": "crm.documents", "status": "ok" })) + }), + ) + } } diff --git a/scenarios/crm-helm/src/facts.rs b/scenarios/crm-helm/src/facts.rs index 7b29337..142c744 100644 --- a/scenarios/crm-helm/src/facts.rs +++ b/scenarios/crm-helm/src/facts.rs @@ -5,7 +5,7 @@ use application_kernel::FactRecord; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, facts as facts_pb}; @@ -91,4 +91,12 @@ impl HelmModule for FactsModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/facts/status", + get(|| async { Json(serde_json::json!({ "module": "crm.facts", "status": "ok" })) }), + ) + } } diff --git a/scenarios/crm-helm/src/lib.rs b/scenarios/crm-helm/src/lib.rs new file mode 100644 index 0000000..f71335d --- /dev/null +++ b/scenarios/crm-helm/src/lib.rs @@ -0,0 +1,267 @@ +// Copyright 2024-2026 Reflective Labs +// SPDX-License-Identifier: MIT + +//! Headless CRM Helm scenario library. +//! +//! Assembles the 7 CRM gRPC modules over an in-memory kernel store and +//! event substrate. No TCP bind, no RunwayAppHost, no StorageKit. +//! +//! ## Hub / lease wiring +//! +//! All 7 module constructors accept `_store: AppKernelStore` and currently +//! ignore it (the gRPC service structs carry the store directly). None of the +//! module constructors accept a hub or a lease store as parameters — both are +//! allocated here and available for future module upgrades, but zero of the +//! 7 modules consume them today. +//! +//! ## RFL-171 T7 +//! +//! This file is the headless variant produced for atelier-showcase as part of +//! Seam A (helm-event-substrate extraction). The runway-backed composition +//! root lives at `helms/apps/crm-helm/src/main.rs`. + +pub mod conversations; +pub mod documents; +pub mod facts; +pub mod metadata; +pub mod opportunities; +pub mod parties; +pub mod proto; +pub mod shared; +pub mod truths; +pub mod workbench; +pub mod workflow; + +use std::sync::Arc; + +use application_storage::{AppKernelStore, InMemoryKernelStore}; +use helm_event_substrate::{EventHub, InMemoryLeaseStore}; +use helm_module_contracts::HelmModule; +use serde_json::{Value, json}; + +/// A CRM event emitted by the headless scenario. +#[derive(Debug, Clone)] +pub struct CrmEvent { + pub sequence: u64, + pub kind: String, + pub payload: Value, +} + +/// Drives a headless CRM assembly and returns the assembled router + events. +pub struct CrmHelmRun { + pub module_ids: Vec<&'static str>, + pub events: Vec, + pub router: axum::Router, +} + +impl CrmHelmRun { + /// Assemble the 7 CRM modules over an in-memory store and substrate. + /// + /// Returns the assembled router and a JSONL-ready event trace showing the + /// assembly and init sequence. + pub async fn assemble() -> anyhow::Result { + let store = AppKernelStore::Memory(InMemoryKernelStore::default_local()); + + // Substrate — allocated here, available for future module upgrades. + // Currently zero of the 7 module constructors consume hub or leases. + let _hub = EventHub::with_capacity(1024); + let _lease_store = Arc::new(InMemoryLeaseStore::new()); + + let modules: Vec> = vec![ + Arc::new(parties::PartiesModule::new(store.clone())), + Arc::new(opportunities::OpportunitiesModule::new(store.clone())), + Arc::new(conversations::ConversationsModule::new(store.clone())), + Arc::new(documents::DocumentsModule::new(store.clone())), + Arc::new(workflow::WorkflowModule::new(store.clone())), + Arc::new(facts::FactsModule::new(store.clone())), + Arc::new(metadata::MetadataModule::new(store)), + ]; + + let mut events = Vec::new(); + let mut router = axum::Router::new(); + let mut module_ids = Vec::new(); + + for module in modules { + let id = module.module_id(); + module.init().await?; + events.push(CrmEvent { + sequence: events.len() as u64 + 1, + kind: "module.init".to_string(), + payload: json!({ "module_id": id }), + }); + router = router.merge(module.clone().router()); + module_ids.push(id); + } + + events.push(CrmEvent { + sequence: events.len() as u64 + 1, + kind: "assembly.complete".to_string(), + payload: json!({ + "module_count": module_ids.len(), + "module_ids": module_ids, + "hub_capacity": 1024, + "lease_store": "InMemoryLeaseStore", + "hub_consumers": 0, + "lease_consumers": 0, + }), + }); + + Ok(Self { + module_ids, + events, + router, + }) + } + + /// Emit events as JSONL. + pub fn jsonl(&self) -> String { + let mut out = String::new(); + for event in &self.events { + out.push_str( + &serde_json::to_string(&json!({ + "sequence": event.sequence, + "kind": event.kind, + "payload": event.payload, + })) + .expect("CrmEvent serializes"), + ); + out.push('\n'); + } + out + } +} + +#[cfg(test)] +mod tests { + use axum::body::Body; + use http::Request; + use tower::ServiceExt; + + use super::*; + + /// Assembly emits init events for all 7 modules followed by an + /// assembly.complete event. + #[tokio::test] + async fn assembly_emits_module_init_and_complete_events() { + let run = CrmHelmRun::assemble().await.expect("assembly succeeds"); + + assert_eq!(run.module_ids.len(), 7); + + let init_events: Vec<_> = run + .events + .iter() + .filter(|e| e.kind == "module.init") + .collect(); + assert_eq!(init_events.len(), 7, "one init event per module"); + + let complete = run + .events + .last() + .expect("at least one event"); + assert_eq!(complete.kind, "assembly.complete"); + assert_eq!( + complete.payload["module_count"], + serde_json::json!(7) + ); + // Honest: hub and leases are allocated but no module consumes them yet. + assert_eq!(complete.payload["hub_consumers"], serde_json::json!(0)); + assert_eq!(complete.payload["lease_consumers"], serde_json::json!(0)); + } + + /// Each module family answers its status route with HTTP 200 via oneshot. + #[tokio::test] + async fn assembled_router_answers_all_module_status_routes() { + let run = CrmHelmRun::assemble().await.expect("assembly succeeds"); + + let routes = [ + "/crm/parties/status", + "/crm/opportunities/status", + "/crm/conversations/status", + "/crm/documents/status", + "/crm/workflow/status", + "/crm/facts/status", + "/crm/metadata/status", + ]; + + for path in routes { + let req = Request::builder() + .method("GET") + .uri(path) + .body(Body::empty()) + .expect("request builds"); + + let response = run + .router + .clone() + .oneshot(req) + .await + .expect("router responds"); + + assert_eq!( + response.status().as_u16(), + 200, + "module route {path} must return 200" + ); + } + } + + /// JSONL output is valid JSON on every line and includes assembly and probe events. + #[tokio::test] + async fn jsonl_output_is_valid_per_line() { + let run = CrmHelmRun::assemble().await.expect("assembly succeeds"); + let jsonl = run.jsonl(); + + for line in jsonl.lines() { + let parsed: serde_json::Value = + serde_json::from_str(line).expect("each JSONL line parses as JSON"); + assert!( + parsed.get("sequence").is_some(), + "every line has a sequence field" + ); + assert!( + parsed.get("kind").is_some(), + "every line has a kind field" + ); + } + + // 7 init events + 1 assembly.complete + assert_eq!(jsonl.lines().count(), 8); + } + + /// InMemoryLeaseStore direct ownership check — honest about the absence of + /// module-surface lease consumption in the current T7 state. + /// + /// Until a module constructor accepts a LeaseStore, this test exercises the + /// store's contract directly: acquire succeeds for the first holder and + /// returns HeldByOther for a competing holder while the lease is live. + #[tokio::test] + async fn in_memory_lease_store_contract_acquires_and_blocks() { + use helm_event_substrate::{AcquireOutcome, InMemoryLeaseStore, LeaseScope, LeaseStore}; + use std::time::Duration; + + let store = InMemoryLeaseStore::new(); + let scope = LeaseScope { + org_id: "org-001".to_string(), + app_id: "crm-helm".to_string(), + session_id: "session-001".to_string(), + }; + + let outcome_a = store + .try_acquire(&scope, "holder-a", Duration::from_secs(30)) + .await + .expect("acquire does not error"); + assert!( + matches!(outcome_a, AcquireOutcome::Acquired(_)), + "first acquire succeeds" + ); + + let outcome_b = store + .try_acquire(&scope, "holder-b", Duration::from_secs(30)) + .await + .expect("acquire does not error"); + assert!( + matches!(outcome_b, AcquireOutcome::HeldByOther(_)), + "second holder sees HeldByOther while first holds the lease" + ); + } +} diff --git a/scenarios/crm-helm/src/main.rs b/scenarios/crm-helm/src/main.rs index 44e1fe6..2310c01 100644 --- a/scenarios/crm-helm/src/main.rs +++ b/scenarios/crm-helm/src/main.rs @@ -1,86 +1,75 @@ -#![allow(clippy::result_large_err)] +// Copyright 2024-2026 Reflective Labs +// SPDX-License-Identifier: MIT -//! CRM Helm Showcase +//! CRM Helm headless scenario binary. //! -//! Demonstrates how Runtime Runway + Helm modules compose into a thin app binary. -//! Phase 6b wires the 7 CRM gRPC modules extracted from helms/application-server. - -mod conversations; -mod documents; -mod facts; -mod metadata; -mod opportunities; -mod parties; -mod proto; -mod shared; -mod truths; -mod workbench; -mod workflow; - -use std::sync::Arc; +//! Assembles 7 CRM modules over an in-memory kernel store and event substrate, +//! drives a demo flow via `tower::ServiceExt::oneshot`, and prints JSONL output. +//! +//! No TCP bind. No RunwayAppHost. No StorageKit. -use application_storage::{AppKernelStore, InMemoryKernelStore}; -use runway_app_host::{ - AppExecutionPacket, BoundaryRegistration, BoundaryStatus, ContractLayer, MountKind, - MountedModule, RunwayAppHost, -}; -use runway_storage::StorageKit; +use axum::body::Body; +use http::Request; +use scenario_crm_helm::CrmHelmRun; +use tower::ServiceExt; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - let packet = AppExecutionPacket::new( - "crm-helm", - "CRM Helm Showcase", - "CRM gRPC services composed via HelmModule — Phase 6b showcase", - "/crm", - ) - .with_mounted_module(MountedModule::new("crm.parties", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.opportunities", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.conversations", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.documents", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.workflow", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.facts", MountKind::Mounted)) - .with_mounted_module(MountedModule::new("crm.metadata", MountKind::Mounted)) - .with_boundary(BoundaryRegistration::new( - ContractLayer::Helm, - vec![ - "crm.parties".to_string(), - "crm.opportunities".to_string(), - "crm.conversations".to_string(), - "crm.documents".to_string(), - "crm.workflow".to_string(), - "crm.facts".to_string(), - "crm.metadata".to_string(), - ], - BoundaryStatus::Mounted, - )); + let run = CrmHelmRun::assemble().await?; + + // Drive a representative request through each module's status route. + let module_routes = [ + "/crm/parties/status", + "/crm/opportunities/status", + "/crm/conversations/status", + "/crm/documents/status", + "/crm/workflow/status", + "/crm/facts/status", + "/crm/metadata/status", + ]; + + let mut probe_events: Vec = Vec::new(); + + for path in module_routes { + let req = Request::builder() + .method("GET") + .uri(path) + .body(Body::empty()) + .expect("request builds"); - // Shared in-memory kernel store — all 7 modules share one store instance - // so writes in one service are visible to reads in another. - let store = AppKernelStore::Memory(InMemoryKernelStore::default_local()); + let response = run + .router + .clone() + .oneshot(req) + .await + .expect("router responds"); - let storage = StorageKit::local("crm-helm-local.db").await?; + let status = response.status().as_u16(); + probe_events.push(serde_json::json!({ + "kind": "probe.response", + "path": path, + "status": status, + })); + } - let host = RunwayAppHost::builder(packet) - .with_storage(storage) - .mount(Arc::new(parties::PartiesModule::new(store.clone()))) - .mount(Arc::new(opportunities::OpportunitiesModule::new( - store.clone(), - ))) - .mount(Arc::new(conversations::ConversationsModule::new( - store.clone(), - ))) - .mount(Arc::new(documents::DocumentsModule::new(store.clone()))) - .mount(Arc::new(workflow::WorkflowModule::new(store.clone()))) - .mount(Arc::new(facts::FactsModule::new(store.clone()))) - .mount(Arc::new(metadata::MetadataModule::new(store))) - .build() - .await?; + // Print assembly events + print!("{}", run.jsonl()); - // TODO(Phase 9/truth-execution): mount TruthCatalog module once - // feat/helm-truth-execution is merged. + // Print probe events as JSONL + let probe_sequence_start = run.events.len() as u64 + 1; + for (i, ev) in probe_events.iter().enumerate() { + let line = serde_json::json!({ + "sequence": probe_sequence_start + i as u64, + "kind": ev["kind"], + "payload": { + "path": ev["path"], + "status": ev["status"], + } + }); + println!("{}", serde_json::to_string(&line).expect("probe event serializes")); + } - host.serve().await + Ok(()) } diff --git a/scenarios/crm-helm/src/metadata.rs b/scenarios/crm-helm/src/metadata.rs index de7f16c..573178d 100644 --- a/scenarios/crm-helm/src/metadata.rs +++ b/scenarios/crm-helm/src/metadata.rs @@ -5,7 +5,7 @@ use application_kernel::{Actor, CrmKernel, ObjectDefinitionUpsert, ViewDefinitionUpsert}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, metadata as metadata_pb}; @@ -156,4 +156,14 @@ impl HelmModule for MetadataModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/metadata/status", + get(|| async { + Json(serde_json::json!({ "module": "crm.metadata", "status": "ok" })) + }), + ) + } } diff --git a/scenarios/crm-helm/src/opportunities.rs b/scenarios/crm-helm/src/opportunities.rs index 804befa..96b4aa2 100644 --- a/scenarios/crm-helm/src/opportunities.rs +++ b/scenarios/crm-helm/src/opportunities.rs @@ -5,7 +5,7 @@ use application_kernel::{Money, OpportunityAdvance, OpportunityCreate}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, opportunities as opportunities_pb}; @@ -134,4 +134,14 @@ impl HelmModule for OpportunitiesModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/opportunities/status", + get(|| async { + Json(serde_json::json!({ "module": "crm.opportunities", "status": "ok" })) + }), + ) + } } diff --git a/scenarios/crm-helm/src/parties.rs b/scenarios/crm-helm/src/parties.rs index 3bd826d..5f94498 100644 --- a/scenarios/crm-helm/src/parties.rs +++ b/scenarios/crm-helm/src/parties.rs @@ -5,7 +5,7 @@ use application_kernel::{CrmKernel, OrganizationUpsert, PersonUpsert, RelationshipLink}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, parties as parties_pb}; @@ -189,4 +189,12 @@ impl HelmModule for PartiesModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/parties/status", + get(|| async { Json(serde_json::json!({ "module": "crm.parties", "status": "ok" })) }), + ) + } } diff --git a/scenarios/crm-helm/src/workflow.rs b/scenarios/crm-helm/src/workflow.rs index 248c530..57c6aae 100644 --- a/scenarios/crm-helm/src/workflow.rs +++ b/scenarios/crm-helm/src/workflow.rs @@ -5,7 +5,7 @@ use application_kernel::{WorkflowCaseAdvance, WorkflowCaseCreate}; use application_storage::{AppKernelStore, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; -use runway_app_host::HelmModule; +use helm_module_contracts::HelmModule; use tonic::{Request, Response, Status}; use crate::proto::{common as pb, workflow as workflow_pb}; @@ -110,4 +110,14 @@ impl HelmModule for WorkflowModule { async fn init(&self) -> anyhow::Result<()> { Ok(()) } + + fn router(self: std::sync::Arc) -> axum::Router { + use axum::{Json, routing::get}; + axum::Router::new().route( + "/crm/workflow/status", + get(|| async { + Json(serde_json::json!({ "module": "crm.workflow", "status": "ok" })) + }), + ) + } }