From 943c820f3061b056852d0881dcc744dcb03f165b Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:21:39 -0600 Subject: [PATCH] further split treetest modules and docs --- treetest/src/app/ui/panels.rs | 263 +--------- treetest/src/app/ui/panels/chrome.rs | 184 +++++++ treetest/src/app/ui/panels/lists.rs | 93 ++++ treetest/src/scenarios.rs | 314 +----------- treetest/src/scenarios/complex.rs | 94 ++++ treetest/src/scenarios/simple.rs | 226 +++++++++ treetest/src/sim/actions.rs | 328 +------------ treetest/src/sim/actions/calls.rs | 234 +++++++++ treetest/src/sim/actions/driver.rs | 62 +++ treetest/src/sim/actions/queries.rs | 53 ++ treetest/src/sim/runtime.rs | 456 +----------------- treetest/src/sim/runtime/dispatch.rs | 156 ++++++ treetest/src/sim/runtime/events.rs | 4 + .../src/sim/runtime/events/application.rs | 127 +++++ treetest/src/sim/runtime/events/local.rs | 136 ++++++ treetest/src/sim/runtime/learning.rs | 56 +++ 16 files changed, 1450 insertions(+), 1336 deletions(-) create mode 100644 treetest/src/app/ui/panels/chrome.rs create mode 100644 treetest/src/app/ui/panels/lists.rs create mode 100644 treetest/src/scenarios/complex.rs create mode 100644 treetest/src/scenarios/simple.rs create mode 100644 treetest/src/sim/actions/calls.rs create mode 100644 treetest/src/sim/actions/driver.rs create mode 100644 treetest/src/sim/actions/queries.rs create mode 100644 treetest/src/sim/runtime/dispatch.rs create mode 100644 treetest/src/sim/runtime/events.rs create mode 100644 treetest/src/sim/runtime/events/application.rs create mode 100644 treetest/src/sim/runtime/events/local.rs create mode 100644 treetest/src/sim/runtime/learning.rs diff --git a/treetest/src/app/ui/panels.rs b/treetest/src/app/ui/panels.rs index f3c0e30..093d94a 100644 --- a/treetest/src/app/ui/panels.rs +++ b/treetest/src/app/ui/panels.rs @@ -1,261 +1,6 @@ -//! Non-inspector UI panels. +//! Non-inspector UI panel entry point. -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::Line, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, -}; +mod chrome; +mod lists; -use crate::{ - model::{Selection, format_hook_ref, format_leaf_ref, format_path}, - sim::{InspectorMode, RecordedEvent, Simulation}, -}; - -use super::super::App; - -impl App { - pub(crate) fn render(&self, frame: &mut Frame<'_>) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(14), - Constraint::Length(8), - ]) - .split(frame.area()); - - self.render_header(frame, chunks[0]); - self.render_body(frame, chunks[1]); - self.render_footer(frame, chunks[2]); - } - - fn render_header(&self, frame: &mut Frame<'_>, area: Rect) { - let mode = match self.simulation.inspector_mode { - InspectorMode::GroundTruth => "ground truth", - InspectorMode::Realistic => "realistic", - }; - let title = format!( - "treetest | scenario {} / {}: {} | {}", - self.scenario_index + 1, - self.scenarios.len(), - self.scenarios[self.scenario_index].name, - mode - ); - frame.render_widget( - Paragraph::new(title).block(Block::default().borders(Borders::ALL).title("Scenario")), - area, - ); - } - - fn render_body(&self, frame: &mut Frame<'_>, area: Rect) { - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(34), - Constraint::Percentage(36), - Constraint::Percentage(32), - ]) - .split(area); - - let scenario_items = self - .scenarios - .iter() - .enumerate() - .map(|(index, scenario)| { - let label = if index == self.scenario_index { - format!("> {}", scenario.name) - } else { - format!(" {}", scenario.name) - }; - ListItem::new(label) - }) - .collect::>(); - frame.render_widget( - List::new(scenario_items) - .block(Block::default().borders(Borders::ALL).title("Scenarios")), - columns[0], - ); - - let center = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(54), Constraint::Percentage(46)]) - .split(columns[1]); - self.render_selection_list(frame, center[0]); - self.render_inspector(frame, center[1]); - - let right = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(columns[2]); - self.render_trace(frame, right[0]); - self.render_hooks(frame, right[1]); - } - - fn render_selection_list(&self, frame: &mut Frame<'_>, area: Rect) { - let items = self - .selections - .iter() - .enumerate() - .map(|(index, selection)| { - let label = match selection { - Selection::Node(node_id) => { - let node = self.simulation.node(*node_id); - format!( - "{} {}", - if index == self.selection_index { - ">" - } else { - " " - }, - node.display_path() - ) - } - Selection::Leaf { node_id, leaf_name } => format!( - "{} {}", - if index == self.selection_index { - ">" - } else { - " " - }, - format_leaf_ref(&self.simulation.node(*node_id).path, leaf_name) - ), - }; - ListItem::new(label) - }) - .collect::>(); - frame.render_widget( - List::new(items).block(Block::default().borders(Borders::ALL).title("Tree")), - area, - ); - } - - fn render_trace(&self, frame: &mut Frame<'_>, area: Rect) { - let items = self - .simulation - .trace - .iter() - .rev() - .take(12) - .map(|event| { - ListItem::new(format!( - "#{:03} {} | {}", - event.tick, event.node_path, event.summary - )) - }) - .collect::>(); - frame.render_widget( - List::new(items).block(Block::default().borders(Borders::ALL).title("Trace")), - area, - ); - } - - fn render_hooks(&self, frame: &mut Frame<'_>, area: Rect) { - let items = self - .simulation - .hooks - .values() - .map(|hook| { - let status = if hook.closed { "closed" } else { "open" }; - ListItem::new(format!( - "{} -> {} [{}] {}", - format_hook_ref(&hook.host_path, hook.hook_id), - format_path(&hook.peer_path), - status, - hook.last_message, - )) - }) - .collect::>(); - frame.render_widget( - List::new(items).block(Block::default().borders(Borders::ALL).title("Hooks")), - area, - ); - } - - fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) { - let help = vec![ - Line::from(self.status.clone()).style(Style::default().add_modifier(Modifier::BOLD)), - Line::from( - "Keys: arrows move selection/scenario | i introspect | e echo leaf | p ping | c chunked | h open chat | d chat data | b chat bye | f invalid peer | g toggle ground-truth/realistic | m clear deeper memory + realistic | s step | a autoplay | q quit", - ), - Line::from(format!( - "Current selection: {}", - self.simulation.selection_summary(self.selected()) - )), - Line::from(match self.simulation.recorded_events.last() { - Some(RecordedEvent::Data { - node_path, message, .. - }) => { - format!( - "Last local event: Data at {node_path} ({})", - String::from_utf8_lossy(&message.data) - ) - } - Some(RecordedEvent::Fault { - node_path, message, .. - }) => { - format!( - "Last local event: Fault at {node_path} (0x{:02X})", - message.fault.0 - ) - } - Some(RecordedEvent::Call { - node_path, message, .. - }) => { - format!( - "Last local event: Call at {node_path} ({})", - message.procedure_id - ) - } - None => "Last local event: none yet".to_owned(), - }), - ]; - - frame.render_widget( - Paragraph::new(help.into_iter().collect::>()) - .block(Block::default().borders(Borders::ALL).title("Status")) - .wrap(Wrap { trim: true }), - area, - ); - } -} - -pub(crate) fn build_selections(simulation: &Simulation) -> Vec { - let mut selections = Vec::new(); - let node_ids: Vec<_> = match simulation.inspector_mode { - InspectorMode::GroundTruth => simulation.tree.nodes.iter().map(|node| node.id).collect(), - InspectorMode::Realistic => simulation - .root_knowledge - .known_paths() - .into_iter() - .filter_map(|path| simulation.tree.find_by_path(&path)) - .collect(), - }; - - for node_id in node_ids { - let node = simulation.node(node_id); - selections.push(Selection::Node(node.id)); - match simulation.inspector_mode { - InspectorMode::GroundTruth => { - for leaf in &node.leaves { - selections.push(Selection::Leaf { - node_id: node.id, - leaf_name: leaf.name.clone(), - }); - } - } - InspectorMode::Realistic => { - if let Some(learned) = simulation.root_knowledge.node(&node.path) { - for leaf in &learned.leaves { - selections.push(Selection::Leaf { - node_id: node.id, - leaf_name: leaf.leaf_name.clone(), - }); - } - } - } - } - } - selections -} +pub(crate) use lists::build_selections; diff --git a/treetest/src/app/ui/panels/chrome.rs b/treetest/src/app/ui/panels/chrome.rs new file mode 100644 index 0000000..c8c0cca --- /dev/null +++ b/treetest/src/app/ui/panels/chrome.rs @@ -0,0 +1,184 @@ +//! Frame layout, trace, hook, and footer panels. + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use crate::{ + model::{format_hook_ref, format_path}, + sim::{InspectorMode, RecordedEvent}, +}; + +use super::super::super::App; + +impl App { + pub(crate) fn render(&self, frame: &mut Frame<'_>) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(14), + Constraint::Length(8), + ]) + .split(frame.area()); + + self.render_header(frame, chunks[0]); + self.render_body(frame, chunks[1]); + self.render_footer(frame, chunks[2]); + } + + fn render_header(&self, frame: &mut Frame<'_>, area: Rect) { + let mode = match self.simulation.inspector_mode { + InspectorMode::GroundTruth => "ground truth", + InspectorMode::Realistic => "realistic", + }; + let title = format!( + "treetest | scenario {} / {}: {} | {}", + self.scenario_index + 1, + self.scenarios.len(), + self.scenarios[self.scenario_index].name, + mode + ); + frame.render_widget( + Paragraph::new(title).block(Block::default().borders(Borders::ALL).title("Scenario")), + area, + ); + } + + fn render_body(&self, frame: &mut Frame<'_>, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(34), + Constraint::Percentage(36), + Constraint::Percentage(32), + ]) + .split(area); + + let scenario_items = self + .scenarios + .iter() + .enumerate() + .map(|(index, scenario)| { + let label = if index == self.scenario_index { + format!("> {}", scenario.name) + } else { + format!(" {}", scenario.name) + }; + ListItem::new(label) + }) + .collect::>(); + frame.render_widget( + List::new(scenario_items) + .block(Block::default().borders(Borders::ALL).title("Scenarios")), + columns[0], + ); + + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(54), Constraint::Percentage(46)]) + .split(columns[1]); + self.render_selection_list(frame, center[0]); + self.render_inspector(frame, center[1]); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(columns[2]); + self.render_trace(frame, right[0]); + self.render_hooks(frame, right[1]); + } + + fn render_trace(&self, frame: &mut Frame<'_>, area: Rect) { + let items = self + .simulation + .trace + .iter() + .rev() + .take(12) + .map(|event| { + ListItem::new(format!( + "#{:03} {} | {}", + event.tick, event.node_path, event.summary + )) + }) + .collect::>(); + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL).title("Trace")), + area, + ); + } + + fn render_hooks(&self, frame: &mut Frame<'_>, area: Rect) { + let items = self + .simulation + .hooks + .values() + .map(|hook| { + let status = if hook.closed { "closed" } else { "open" }; + ListItem::new(format!( + "{} -> {} [{}] {}", + format_hook_ref(&hook.host_path, hook.hook_id), + format_path(&hook.peer_path), + status, + hook.last_message, + )) + }) + .collect::>(); + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL).title("Hooks")), + area, + ); + } + + fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) { + let help = vec![ + Line::from(self.status.clone()).style(Style::default().add_modifier(Modifier::BOLD)), + Line::from( + "Keys: arrows move selection/scenario | i introspect | e echo leaf | p ping | c chunked | h open chat | d chat data | b chat bye | f invalid peer | g toggle ground-truth/realistic | m clear deeper memory + realistic | s step | a autoplay | q quit", + ), + Line::from(format!( + "Current selection: {}", + self.simulation.selection_summary(self.selected()) + )), + Line::from(match self.simulation.recorded_events.last() { + Some(RecordedEvent::Data { + node_path, message, .. + }) => { + format!( + "Last local event: Data at {node_path} ({})", + String::from_utf8_lossy(&message.data) + ) + } + Some(RecordedEvent::Fault { + node_path, message, .. + }) => { + format!( + "Last local event: Fault at {node_path} (0x{:02X})", + message.fault.0 + ) + } + Some(RecordedEvent::Call { + node_path, message, .. + }) => { + format!( + "Last local event: Call at {node_path} ({})", + message.procedure_id + ) + } + None => "Last local event: none yet".to_owned(), + }), + ]; + + frame.render_widget( + Paragraph::new(help.into_iter().collect::>()) + .block(Block::default().borders(Borders::ALL).title("Status")) + .wrap(Wrap { trim: true }), + area, + ); + } +} diff --git a/treetest/src/app/ui/panels/lists.rs b/treetest/src/app/ui/panels/lists.rs new file mode 100644 index 0000000..7f3b75d --- /dev/null +++ b/treetest/src/app/ui/panels/lists.rs @@ -0,0 +1,93 @@ +//! Tree and selection list rendering. + +use ratatui::{ + Frame, + layout::Rect, + widgets::{Block, Borders, List, ListItem}, +}; + +use crate::{ + model::{Selection, format_leaf_ref}, + sim::{InspectorMode, Simulation}, +}; + +use super::super::super::App; + +impl App { + pub(super) fn render_selection_list(&self, frame: &mut Frame<'_>, area: Rect) { + let items = self + .selections + .iter() + .enumerate() + .map(|(index, selection)| { + let label = match selection { + Selection::Node(node_id) => { + let node = self.simulation.node(*node_id); + format!( + "{} {}", + if index == self.selection_index { + ">" + } else { + " " + }, + node.display_path() + ) + } + Selection::Leaf { node_id, leaf_name } => format!( + "{} {}", + if index == self.selection_index { + ">" + } else { + " " + }, + format_leaf_ref(&self.simulation.node(*node_id).path, leaf_name) + ), + }; + ListItem::new(label) + }) + .collect::>(); + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL).title("Tree")), + area, + ); + } +} + +pub(crate) fn build_selections(simulation: &Simulation) -> Vec { + let mut selections = Vec::new(); + let node_ids: Vec<_> = match simulation.inspector_mode { + InspectorMode::GroundTruth => simulation.tree.nodes.iter().map(|node| node.id).collect(), + InspectorMode::Realistic => simulation + .root_knowledge + .known_paths() + .into_iter() + .filter_map(|path| simulation.tree.find_by_path(&path)) + .collect(), + }; + + for node_id in node_ids { + let node = simulation.node(node_id); + selections.push(Selection::Node(node.id)); + match simulation.inspector_mode { + InspectorMode::GroundTruth => { + for leaf in &node.leaves { + selections.push(Selection::Leaf { + node_id: node.id, + leaf_name: leaf.name.clone(), + }); + } + } + InspectorMode::Realistic => { + if let Some(learned) = simulation.root_knowledge.node(&node.path) { + for leaf in &learned.leaves { + selections.push(Selection::Leaf { + node_id: node.id, + leaf_name: leaf.leaf_name.clone(), + }); + } + } + } + } + } + selections +} diff --git a/treetest/src/scenarios.rs b/treetest/src/scenarios.rs index bbfdd9d..f4b1fb9 100644 --- a/treetest/src/scenarios.rs +++ b/treetest/src/scenarios.rs @@ -1,310 +1,16 @@ -//! Demo scenarios ranging from simple introspection to multi-hop hooks. +//! Built-in demo scenarios. +//! +//! Scenarios are grouped into smaller modules so simple onboarding flows and the +//! larger sandbox topology are easy to navigate independently. -use crate::model::{ - EndpointProcedureKind, EndpointProcedureSpec, LeafKind, LeafSpec, NodeId, NodeSpec, - ScenarioDefinition, Selection, -}; +mod complex; +mod simple; -const PROC_PING: &str = "demo.endpoint.v1.control.ping"; -const PROC_CHUNKED: &str = "demo.endpoint.v1.stream.chunked_greeting"; -const PROC_CHAT: &str = "demo.endpoint.v1.chat.session"; -const PROC_ECHO: &str = "demo.leaf.v1.echo.invoke"; +use crate::model::ScenarioDefinition; /// Returns all built-in demo scenarios. pub fn built_in_scenarios() -> Vec { - vec![ - local_introspection(), - echo_leaf(), - branch_routing(), - bidirectional_chat(), - fault_showcase(), - complex_tree(), - ] -} - -fn local_introspection() -> ScenarioDefinition { - ScenarioDefinition { - name: "Local Introspection".to_owned(), - description: - "Inspect the root and its immediate child using the required empty procedure id." - .to_owned(), - highlights: vec![ - "Blank procedure calls map to protocol introspection.".to_owned(), - "Leaf introspection uses the same hook path as endpoint introspection.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "The operator-controlled root endpoint.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_PING.to_owned(), - description: "Single-packet endpoint response for baseline testing.".to_owned(), - kind: EndpointProcedureKind::Ping, - }], - children: vec![NodeSpec { - segment: "alpha".to_owned(), - title: "Alpha".to_owned(), - description: "A minimal child endpoint with one echo leaf.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Echoes bytes back through the declared hook.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: Vec::new(), - children: Vec::new(), - }], - }, - initial_selection: Selection::Node(NodeId(0)), - } -} - -fn echo_leaf() -> ScenarioDefinition { - ScenarioDefinition { - name: "Echo Leaf".to_owned(), - description: "Call a concrete leaf and watch the hook finish normally.".to_owned(), - highlights: vec![ - "The leaf uses the built-in `Echo` behavior from the core runtime.".to_owned(), - "The final response sets `end_hook = true`.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "The operator origin for all demo calls.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: Vec::new(), - children: vec![NodeSpec { - segment: "services".to_owned(), - title: "Services".to_owned(), - description: "Hosts protocol-visible demo leaves.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Simple echo leaf.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_CHUNKED.to_owned(), - description: "Three response packets with a clear final chunk.".to_owned(), - kind: EndpointProcedureKind::ChunkedGreeting, - }], - children: Vec::new(), - }], - }, - initial_selection: Selection::Leaf { - node_id: NodeId(1), - leaf_name: "echo".to_owned(), - }, - } -} - -fn branch_routing() -> ScenarioDefinition { - ScenarioDefinition { - name: "Branch Routing".to_owned(), - description: "Demonstrates longest-prefix routing across sibling branches.".to_owned(), - highlights: vec![ - "Packets descend through the most specific child path.".to_owned(), - "Responses route back upward and then down into the hook host subtree.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "The routing apex.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: Vec::new(), - children: vec![ - NodeSpec { - segment: "alpha".to_owned(), - title: "Alpha".to_owned(), - description: "Intermediate branch.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: Vec::new(), - children: vec![NodeSpec { - segment: "beta".to_owned(), - title: "Beta".to_owned(), - description: "Nested endpoint for longest-prefix routing.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Nested echo leaf.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_PING.to_owned(), - description: "Checks routed endpoint procedures.".to_owned(), - kind: EndpointProcedureKind::Ping, - }], - children: Vec::new(), - }], - }, - NodeSpec { - segment: "gamma".to_owned(), - title: "Gamma".to_owned(), - description: "Sibling endpoint used to make the route tree non-trivial." - .to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Sibling echo leaf.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: Vec::new(), - children: Vec::new(), - }, - ], - }, - initial_selection: Selection::Node(NodeId(2)), - } -} - -fn bidirectional_chat() -> ScenarioDefinition { - ScenarioDefinition { - name: "Bidirectional Chat".to_owned(), - description: "Keeps a hook active so the root can continue sending `Data` packets.".to_owned(), - highlights: vec![ - "After activation, either side may send hook data first.".to_owned(), - "The chat handler exists outside the core runtime so the demo can show application-level behavior without changing the protocol.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "The operator-controlled hook host.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: Vec::new(), - children: vec![NodeSpec { - segment: "chat".to_owned(), - title: "Chat Host".to_owned(), - description: "Endpoint with a long-lived hook-backed chat procedure.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_CHAT.to_owned(), - description: "Bidirectional hook that replies until it sees `bye`.".to_owned(), - kind: EndpointProcedureKind::Chat, - }], - children: Vec::new(), - }], - }, - initial_selection: Selection::Node(NodeId(1)), - } -} - -fn fault_showcase() -> ScenarioDefinition { - ScenarioDefinition { - name: "Fault Showcase".to_owned(), - description: "Use valid and invalid calls to trigger protocol-level faults.".to_owned(), - highlights: vec![ - "Unknown leaf and unknown procedure faults are attributed to the declared hook." - .to_owned(), - "Packets with an invalid hook peer are rejected and faulted locally.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "Runs fault-focused experiments.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: Vec::new(), - children: vec![NodeSpec { - segment: "faults".to_owned(), - title: "Fault Lab".to_owned(), - description: "One endpoint with one known leaf and one known procedure.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Valid leaf used to contrast unknown-leaf failures.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_PING.to_owned(), - description: "Known procedure for contrast against unknown procedures." - .to_owned(), - kind: EndpointProcedureKind::Ping, - }], - children: Vec::new(), - }], - }, - initial_selection: Selection::Node(NodeId(1)), - } -} - -fn complex_tree() -> ScenarioDefinition { - ScenarioDefinition { - name: "Complex Tree".to_owned(), - description: "A larger topology that combines leaf calls, endpoint procedures, and nested routing.".to_owned(), - highlights: vec![ - "Use this as a sandbox after learning the smaller scenarios.".to_owned(), - "The tree contains both leaf and endpoint interactions so the UI inspector stays interesting.".to_owned(), - ], - root: NodeSpec { - segment: String::new(), - title: "Root".to_owned(), - description: "Primary operator endpoint.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_PING.to_owned(), - description: "Root-local endpoint procedure for comparison with remote calls.".to_owned(), - kind: EndpointProcedureKind::Ping, - }], - children: vec![ - NodeSpec { - segment: "alpha".to_owned(), - title: "Alpha".to_owned(), - description: "Left branch.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Echo leaf on alpha.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_CHUNKED.to_owned(), - description: "Chunked endpoint response.".to_owned(), - kind: EndpointProcedureKind::ChunkedGreeting, - }], - children: vec![NodeSpec { - segment: "deep".to_owned(), - title: "Alpha Deep".to_owned(), - description: "Nested node for multi-hop traffic.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Deep nested echo leaf.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: Vec::new(), - children: Vec::new(), - }], - }, - NodeSpec { - segment: "beta".to_owned(), - title: "Beta".to_owned(), - description: "Right branch.".to_owned(), - leaves: Vec::new(), - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_CHAT.to_owned(), - description: "Long-lived chat procedure.".to_owned(), - kind: EndpointProcedureKind::Chat, - }], - children: vec![NodeSpec { - segment: "gamma".to_owned(), - title: "Gamma".to_owned(), - description: "Nested branch with its own ping procedure.".to_owned(), - leaves: vec![LeafSpec { - name: "echo".to_owned(), - description: "Gamma echo leaf.".to_owned(), - kind: LeafKind::Echo, - procedures: vec![PROC_ECHO.to_owned()], - }], - endpoint_procedures: vec![EndpointProcedureSpec { - procedure_id: PROC_PING.to_owned(), - description: "Nested ping procedure.".to_owned(), - kind: EndpointProcedureKind::Ping, - }], - children: Vec::new(), - }], - }, - ], - }, - initial_selection: Selection::Node(NodeId(0)), - } + let mut scenarios = simple::scenarios(); + scenarios.extend(complex::scenarios()); + scenarios } diff --git a/treetest/src/scenarios/complex.rs b/treetest/src/scenarios/complex.rs new file mode 100644 index 0000000..0da3924 --- /dev/null +++ b/treetest/src/scenarios/complex.rs @@ -0,0 +1,94 @@ +//! Larger sandbox scenarios. + +use crate::model::{ + EndpointProcedureKind, EndpointProcedureSpec, LeafKind, LeafSpec, NodeId, NodeSpec, + ScenarioDefinition, Selection, +}; + +use super::simple::{PROC_CHAT, PROC_CHUNKED, PROC_ECHO, PROC_PING}; + +pub(super) fn scenarios() -> Vec { + vec![complex_tree()] +} + +fn complex_tree() -> ScenarioDefinition { + ScenarioDefinition { + name: "Complex Tree".to_owned(), + description: "A larger topology that combines leaf calls, endpoint procedures, and nested routing.".to_owned(), + highlights: vec![ + "Use this as a sandbox after learning the smaller scenarios.".to_owned(), + "The tree contains both leaf and endpoint interactions so the UI inspector stays interesting.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "Primary operator endpoint.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_PING.to_owned(), + description: "Root-local endpoint procedure for comparison with remote calls.".to_owned(), + kind: EndpointProcedureKind::Ping, + }], + children: vec![ + NodeSpec { + segment: "alpha".to_owned(), + title: "Alpha".to_owned(), + description: "Left branch.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Echo leaf on alpha.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_CHUNKED.to_owned(), + description: "Chunked endpoint response.".to_owned(), + kind: EndpointProcedureKind::ChunkedGreeting, + }], + children: vec![NodeSpec { + segment: "deep".to_owned(), + title: "Alpha Deep".to_owned(), + description: "Nested node for multi-hop traffic.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Deep nested echo leaf.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: Vec::new(), + children: Vec::new(), + }], + }, + NodeSpec { + segment: "beta".to_owned(), + title: "Beta".to_owned(), + description: "Right branch.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_CHAT.to_owned(), + description: "Long-lived chat procedure.".to_owned(), + kind: EndpointProcedureKind::Chat, + }], + children: vec![NodeSpec { + segment: "gamma".to_owned(), + title: "Gamma".to_owned(), + description: "Nested branch with its own ping procedure.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Gamma echo leaf.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_PING.to_owned(), + description: "Nested ping procedure.".to_owned(), + kind: EndpointProcedureKind::Ping, + }], + children: Vec::new(), + }], + }, + ], + }, + initial_selection: Selection::Node(NodeId(0)), + } +} diff --git a/treetest/src/scenarios/simple.rs b/treetest/src/scenarios/simple.rs new file mode 100644 index 0000000..60975fb --- /dev/null +++ b/treetest/src/scenarios/simple.rs @@ -0,0 +1,226 @@ +//! Smaller onboarding scenarios. + +use crate::model::{ + EndpointProcedureKind, EndpointProcedureSpec, LeafKind, LeafSpec, NodeId, NodeSpec, + ScenarioDefinition, Selection, +}; + +pub(super) const PROC_PING: &str = "demo.endpoint.v1.control.ping"; +pub(super) const PROC_CHUNKED: &str = "demo.endpoint.v1.stream.chunked_greeting"; +pub(super) const PROC_CHAT: &str = "demo.endpoint.v1.chat.session"; +pub(super) const PROC_ECHO: &str = "demo.leaf.v1.echo.invoke"; + +pub(super) fn scenarios() -> Vec { + vec![ + local_introspection(), + echo_leaf(), + branch_routing(), + bidirectional_chat(), + fault_showcase(), + ] +} + +fn local_introspection() -> ScenarioDefinition { + ScenarioDefinition { + name: "Local Introspection".to_owned(), + description: + "Inspect the root and its immediate child using the required empty procedure id." + .to_owned(), + highlights: vec![ + "Blank procedure calls map to protocol introspection.".to_owned(), + "Leaf introspection uses the same hook path as endpoint introspection.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "The operator-controlled root endpoint.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_PING.to_owned(), + description: "Single-packet endpoint response for baseline testing.".to_owned(), + kind: EndpointProcedureKind::Ping, + }], + children: vec![NodeSpec { + segment: "alpha".to_owned(), + title: "Alpha".to_owned(), + description: "A minimal child endpoint with one echo leaf.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Echoes bytes back through the declared hook.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: Vec::new(), + children: Vec::new(), + }], + }, + initial_selection: Selection::Node(NodeId(0)), + } +} + +fn echo_leaf() -> ScenarioDefinition { + ScenarioDefinition { + name: "Echo Leaf".to_owned(), + description: "Call a concrete leaf and watch the hook finish normally.".to_owned(), + highlights: vec![ + "The leaf uses the built-in `Echo` behavior from the core runtime.".to_owned(), + "The final response sets `end_hook = true`.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "The operator origin for all demo calls.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: Vec::new(), + children: vec![NodeSpec { + segment: "services".to_owned(), + title: "Services".to_owned(), + description: "Hosts protocol-visible demo leaves.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Simple echo leaf.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_CHUNKED.to_owned(), + description: "Three response packets with a clear final chunk.".to_owned(), + kind: EndpointProcedureKind::ChunkedGreeting, + }], + children: Vec::new(), + }], + }, + initial_selection: Selection::Leaf { + node_id: NodeId(1), + leaf_name: "echo".to_owned(), + }, + } +} + +fn branch_routing() -> ScenarioDefinition { + ScenarioDefinition { + name: "Branch Routing".to_owned(), + description: "Demonstrates longest-prefix routing across sibling branches.".to_owned(), + highlights: vec![ + "Packets descend through the most specific child path.".to_owned(), + "Responses route back upward and then down into the hook host subtree.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "The routing apex.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: Vec::new(), + children: vec![ + NodeSpec { + segment: "alpha".to_owned(), + title: "Alpha".to_owned(), + description: "Intermediate branch.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: Vec::new(), + children: vec![NodeSpec { + segment: "beta".to_owned(), + title: "Beta".to_owned(), + description: "Nested endpoint for longest-prefix routing.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Nested echo leaf.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_PING.to_owned(), + description: "Checks routed endpoint procedures.".to_owned(), + kind: EndpointProcedureKind::Ping, + }], + children: Vec::new(), + }], + }, + NodeSpec { + segment: "gamma".to_owned(), + title: "Gamma".to_owned(), + description: "Sibling endpoint used to make the route tree non-trivial." + .to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Sibling echo leaf.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: Vec::new(), + children: Vec::new(), + }, + ], + }, + initial_selection: Selection::Node(NodeId(2)), + } +} + +fn bidirectional_chat() -> ScenarioDefinition { + ScenarioDefinition { + name: "Bidirectional Chat".to_owned(), + description: "Keeps a hook active so the root can continue sending `Data` packets.".to_owned(), + highlights: vec![ + "After activation, either side may send hook data first.".to_owned(), + "The chat handler exists outside the core runtime so the demo can show application-level behavior without changing the protocol.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "The operator-controlled hook host.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: Vec::new(), + children: vec![NodeSpec { + segment: "chat".to_owned(), + title: "Chat Host".to_owned(), + description: "Endpoint with a long-lived hook-backed chat procedure.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_CHAT.to_owned(), + description: "Bidirectional hook that replies until it sees `bye`.".to_owned(), + kind: EndpointProcedureKind::Chat, + }], + children: Vec::new(), + }], + }, + initial_selection: Selection::Node(NodeId(1)), + } +} + +fn fault_showcase() -> ScenarioDefinition { + ScenarioDefinition { + name: "Fault Showcase".to_owned(), + description: "Use valid and invalid calls to trigger protocol-level faults.".to_owned(), + highlights: vec![ + "Unknown leaf and unknown procedure faults are attributed to the declared hook." + .to_owned(), + "Packets with an invalid hook peer are rejected and faulted locally.".to_owned(), + ], + root: NodeSpec { + segment: String::new(), + title: "Root".to_owned(), + description: "Runs fault-focused experiments.".to_owned(), + leaves: Vec::new(), + endpoint_procedures: Vec::new(), + children: vec![NodeSpec { + segment: "faults".to_owned(), + title: "Fault Lab".to_owned(), + description: "One endpoint with one known leaf and one known procedure.".to_owned(), + leaves: vec![LeafSpec { + name: "echo".to_owned(), + description: "Valid leaf used to contrast unknown-leaf failures.".to_owned(), + kind: LeafKind::Echo, + procedures: vec![PROC_ECHO.to_owned()], + }], + endpoint_procedures: vec![EndpointProcedureSpec { + procedure_id: PROC_PING.to_owned(), + description: "Known procedure for contrast against unknown procedures." + .to_owned(), + kind: EndpointProcedureKind::Ping, + }], + children: Vec::new(), + }], + }, + initial_selection: Selection::Node(NodeId(1)), + } +} diff --git a/treetest/src/sim/actions.rs b/treetest/src/sim/actions.rs index 5e5dc04..a129994 100644 --- a/treetest/src/sim/actions.rs +++ b/treetest/src/sim/actions.rs @@ -1,322 +1,8 @@ -//! Public action helpers exposed to the UI and tests. +//! Simulator action entry point. +//! +//! Public simulator behavior is split into request-style actions, stepping, and +//! small query helpers so UI code can depend on focused APIs. -use crossbeam_channel::TryRecvError; -use unshell::protocol::tree::Endpoint; -use unshell::protocol::{DataMessage, FaultMessage, PacketHeader, PacketType, decode_frame}; - -use crate::model::{NodeId, Selection, format_hook_ref, format_leaf_ref, format_path}; - -use super::types::{ActionResult, RecordedEvent, SimError, Simulation}; - -impl Simulation { - /// Builds and routes an endpoint introspection call from the root. - pub fn call_endpoint_introspection( - &mut self, - node_id: NodeId, - ) -> Result { - let path = self.tree.node(node_id).path.clone(); - self.dispatch_root_call(path.clone(), None, "", Vec::new())?; - Ok(ActionResult { - label: format!("Inspect endpoint {}", format_path(&path)), - hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), - }) - } - - /// Builds and routes a leaf introspection call from the root. - pub fn call_leaf_introspection( - &mut self, - node_id: NodeId, - leaf_name: &str, - ) -> Result { - let node_path = self.tree.node(node_id).path.clone(); - self.require_leaf(node_id, leaf_name)?; - let node = self.tree.node(node_id).clone(); - if let Some(leaf_spec) = node.leaves.iter().find(|leaf| leaf.name == leaf_name) { - self.root_knowledge - .remember_leaf_from_spec(&node, leaf_spec); - } - self.dispatch_root_call(node_path, Some(leaf_name.to_owned()), "", Vec::new())?; - Ok(ActionResult { - label: format!( - "Inspect {}", - format_leaf_ref(&self.node(node_id).path, leaf_name) - ), - hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), - }) - } - - /// Calls a leaf echo procedure using the selected payload. - pub fn call_echo_leaf( - &mut self, - node_id: NodeId, - leaf_name: &str, - text: &str, - ) -> Result { - let node_path = self.tree.node(node_id).path.clone(); - let node_display = self.tree.node(node_id).display_path(); - let node = self.tree.node(node_id).clone(); - let procedures = self.require_leaf(node_id, leaf_name)?.procedures.clone(); - if let Some(leaf_spec) = node - .leaves - .iter() - .find(|known_leaf| known_leaf.name == leaf_name) - { - self.root_knowledge - .remember_leaf_from_spec(&node, leaf_spec); - } - let procedure_id = - procedures - .first() - .cloned() - .ok_or_else(|| SimError::UnknownProcedure { - node_path: node_display.clone(), - procedure_id: "".to_owned(), - })?; - self.dispatch_root_call( - node_path, - Some(leaf_name.to_owned()), - &procedure_id, - text.as_bytes().to_vec(), - )?; - Ok(ActionResult { - label: format!( - "Echo via {}", - format_leaf_ref(&self.node(node_id).path, leaf_name) - ), - hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), - }) - } - - /// Calls an endpoint-level procedure. - pub fn call_endpoint_procedure( - &mut self, - node_id: NodeId, - procedure_id: &str, - data: Vec, - ) -> Result { - let node_path = self.tree.node(node_id).path.clone(); - let node_display = self.tree.node(node_id).display_path(); - self.require_endpoint_procedure(node_id, procedure_id)?; - let node = self.tree.node(node_id).clone(); - if let Some(procedure) = node - .endpoint_procedures - .iter() - .find(|known_procedure| known_procedure.procedure_id == procedure_id) - { - self.root_knowledge - .remember_endpoint_procedure(&node, procedure); - } - self.dispatch_root_call(node_path, None, procedure_id, data)?; - Ok(ActionResult { - label: format!("Call {procedure_id} on {}", node_display), - hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), - }) - } - - /// Sends a raw call without demo-side validation so tests can exercise - /// remote `UnknownLeaf` and `UnknownProcedure` fault behavior. - pub fn call_unchecked( - &mut self, - node_id: NodeId, - dst_leaf: Option<&str>, - procedure_id: &str, - data: Vec, - ) -> Result { - let node_path = self.tree.node(node_id).path.clone(); - let node_display = self.tree.node(node_id).display_path(); - self.dispatch_root_call(node_path, dst_leaf.map(str::to_owned), procedure_id, data)?; - Ok(ActionResult { - label: format!( - "Call {} on {}{}", - if procedure_id.is_empty() { - "" - } else { - procedure_id - }, - node_display, - dst_leaf - .map(|leaf_name| format!( - " {}", - format_leaf_ref(&self.node(node_id).path, leaf_name) - )) - .unwrap_or_default() - ), - hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), - }) - } - - /// Sends more hook data from the root side. - pub fn send_root_hook_data( - &mut self, - hook_id: u64, - text: &str, - end_hook: bool, - ) -> Result { - let snapshot = self - .hooks - .get(&hook_id) - .cloned() - .ok_or(SimError::UnknownHook(hook_id))?; - let frame = self.nodes[self.root_id.0] - .endpoint - .make_data( - snapshot.peer_path.clone(), - hook_id, - snapshot.procedure_id.clone(), - text.as_bytes().to_vec(), - end_hook, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.record_trace( - self.root_id, - format!( - "root queued hook data for {}: {text}", - format_hook_ref(self.node(self.root_id).path.as_slice(), hook_id) - ), - ); - self.process_local_frame(self.root_id, frame)?; - Ok(ActionResult { - label: format!("Send hook data {hook_id}"), - hook_id: Some(hook_id), - }) - } - - /// Injects intentionally invalid traffic to demonstrate `InvalidHookPeer`. - pub fn inject_invalid_peer_data( - &mut self, - from_node_id: NodeId, - to_node_id: NodeId, - hook_id: u64, - procedure_id: &str, - text: &str, - ) -> Result { - let from_path = self.tree.node(from_node_id).path.clone(); - let to_path = self.tree.node(to_node_id).path.clone(); - let header = PacketHeader { - packet_type: PacketType::Data, - src_path: from_path.clone(), - dst_path: to_path.clone(), - dst_leaf: None, - hook_id: Some(hook_id), - }; - let message = DataMessage { - procedure_id: procedure_id.to_owned(), - data: text.as_bytes().to_vec(), - end_hook: false, - }; - let frame = unshell::protocol::encode_packet(&header, &message) - .map_err(|error| SimError::Protocol(error.to_string()))?; - - self.record_trace( - from_node_id, - format!( - "injected invalid peer data toward {} for {}", - format_path(&to_path), - format_hook_ref(self.node(to_node_id).path.as_slice(), hook_id) - ), - ); - self.process_local_frame(from_node_id, frame)?; - Ok(ActionResult { - label: format!( - "Inject invalid peer data for {}", - format_hook_ref(self.node(to_node_id).path.as_slice(), hook_id) - ), - hook_id: Some(hook_id), - }) - } - - /// Processes one queued frame if available. - pub fn step(&mut self) -> Result { - for node_id in 0..self.nodes.len() { - match self.nodes[node_id].rx.try_recv() { - Ok(envelope) => { - self.record_trace( - NodeId(node_id), - format!("received frame via {:?}", envelope.ingress), - ); - let outcome = self.nodes[node_id] - .endpoint - .receive(&envelope.ingress, envelope.frame) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.process_outcome(NodeId(node_id), outcome)?; - return Ok(true); - } - Err(TryRecvError::Disconnected) => { - return Err(SimError::Protocol("mailbox disconnected".to_owned())); - } - Err(TryRecvError::Empty) => {} - } - } - Ok(false) - } - - /// Runs frames until the network becomes idle. - pub fn drain(&mut self) -> Result { - let mut steps = 0; - while self.step()? { - steps += 1; - } - Ok(steps) - } - - /// Returns a compact description of a frame for debugging. - pub fn describe_frame(frame: &[u8]) -> String { - match decode_frame(frame) { - Ok(parsed) => { - let header = parsed.header(); - format!( - "{:?} {} -> {} hook {:?}", - header.packet_type, - format_path(&header.src_path), - format_path(&header.dst_path), - header.hook_id, - ) - } - Err(error) => format!(""), - } - } - - /// Returns the latest fault observed at the root, if any. - pub fn latest_root_fault(&self) -> Option<&FaultMessage> { - self.recorded_events - .iter() - .rev() - .find_map(|event| match event { - RecordedEvent::Fault { - node_path, message, .. - } if node_path == "/" => Some(message), - _ => None, - }) - } - - /// Returns the latest root data message as utf-8 for tests and status text. - pub fn latest_root_data_text(&self) -> Option { - self.recorded_events - .iter() - .rev() - .find_map(|event| match event { - RecordedEvent::Data { - node_path, message, .. - } if node_path == "/" => Some(String::from_utf8_lossy(&message.data).to_string()), - _ => None, - }) - } - - /// Returns all hook ids known to the demo in ascending order. - pub fn hook_ids(&self) -> Vec { - self.hooks.keys().copied().collect() - } - - /// Builds a human-readable description of the current selection. - pub fn selection_summary(&self, selection: &Selection) -> String { - match selection { - Selection::Node(node_id) => { - let node = self.node(*node_id); - format!("{}: {}", node.display_path(), node.title) - } - Selection::Leaf { node_id, leaf_name } => { - format_leaf_ref(&self.node(*node_id).path, leaf_name) - } - } - } -} +mod calls; +mod driver; +mod queries; diff --git a/treetest/src/sim/actions/calls.rs b/treetest/src/sim/actions/calls.rs new file mode 100644 index 0000000..f8e9ce2 --- /dev/null +++ b/treetest/src/sim/actions/calls.rs @@ -0,0 +1,234 @@ +//! Root-issued calls and injected traffic. + +use crate::model::{NodeId, format_hook_ref, format_leaf_ref, format_path}; +use unshell::protocol::{DataMessage, PacketHeader, PacketType}; + +use super::super::types::{ActionResult, SimError, Simulation}; + +impl Simulation { + /// Builds and routes an endpoint introspection call from the root. + /// + /// # Example + /// ```rust + /// use treetest::{model::NodeId, scenarios::built_in_scenarios, sim::Simulation}; + /// + /// let scenario = built_in_scenarios().into_iter().next().unwrap(); + /// let mut simulation = Simulation::new(scenario).unwrap(); + /// let result = simulation.call_endpoint_introspection(NodeId(0)).unwrap(); + /// assert!(result.label.contains("Inspect endpoint")); + /// ``` + pub fn call_endpoint_introspection( + &mut self, + node_id: NodeId, + ) -> Result { + let path = self.tree.node(node_id).path.clone(); + self.dispatch_root_call(path.clone(), None, "", Vec::new())?; + Ok(ActionResult { + label: format!("Inspect endpoint {}", format_path(&path)), + hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), + }) + } + + /// Builds and routes a leaf introspection call from the root. + pub fn call_leaf_introspection( + &mut self, + node_id: NodeId, + leaf_name: &str, + ) -> Result { + let node_path = self.tree.node(node_id).path.clone(); + self.require_leaf(node_id, leaf_name)?; + let node = self.tree.node(node_id).clone(); + if let Some(leaf_spec) = node.leaves.iter().find(|leaf| leaf.name == leaf_name) { + self.root_knowledge + .remember_leaf_from_spec(&node, leaf_spec); + } + self.dispatch_root_call(node_path, Some(leaf_name.to_owned()), "", Vec::new())?; + Ok(ActionResult { + label: format!( + "Inspect {}", + format_leaf_ref(&self.node(node_id).path, leaf_name) + ), + hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), + }) + } + + /// Calls a leaf echo procedure using the selected payload. + pub fn call_echo_leaf( + &mut self, + node_id: NodeId, + leaf_name: &str, + text: &str, + ) -> Result { + let node_path = self.tree.node(node_id).path.clone(); + let node_display = self.tree.node(node_id).display_path(); + let node = self.tree.node(node_id).clone(); + let procedures = self.require_leaf(node_id, leaf_name)?.procedures.clone(); + if let Some(leaf_spec) = node + .leaves + .iter() + .find(|known_leaf| known_leaf.name == leaf_name) + { + self.root_knowledge + .remember_leaf_from_spec(&node, leaf_spec); + } + let procedure_id = + procedures + .first() + .cloned() + .ok_or_else(|| SimError::UnknownProcedure { + node_path: node_display.clone(), + procedure_id: "".to_owned(), + })?; + self.dispatch_root_call( + node_path, + Some(leaf_name.to_owned()), + &procedure_id, + text.as_bytes().to_vec(), + )?; + Ok(ActionResult { + label: format!( + "Echo via {}", + format_leaf_ref(&self.node(node_id).path, leaf_name) + ), + hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), + }) + } + + /// Calls an endpoint-level procedure. + pub fn call_endpoint_procedure( + &mut self, + node_id: NodeId, + procedure_id: &str, + data: Vec, + ) -> Result { + let node_path = self.tree.node(node_id).path.clone(); + let node_display = self.tree.node(node_id).display_path(); + self.require_endpoint_procedure(node_id, procedure_id)?; + let node = self.tree.node(node_id).clone(); + if let Some(procedure) = node + .endpoint_procedures + .iter() + .find(|known_procedure| known_procedure.procedure_id == procedure_id) + { + self.root_knowledge + .remember_endpoint_procedure(&node, procedure); + } + self.dispatch_root_call(node_path, None, procedure_id, data)?; + Ok(ActionResult { + label: format!("Call {procedure_id} on {}", node_display), + hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), + }) + } + + /// Sends a raw call without demo-side validation so tests can exercise + /// remote `UnknownLeaf` and `UnknownProcedure` fault behavior. + pub fn call_unchecked( + &mut self, + node_id: NodeId, + dst_leaf: Option<&str>, + procedure_id: &str, + data: Vec, + ) -> Result { + let node_path = self.tree.node(node_id).path.clone(); + let node_display = self.tree.node(node_id).display_path(); + self.dispatch_root_call(node_path, dst_leaf.map(str::to_owned), procedure_id, data)?; + Ok(ActionResult { + label: format!( + "Call {} on {}{}", + if procedure_id.is_empty() { + "" + } else { + procedure_id + }, + node_display, + dst_leaf + .map(|leaf_name| format!( + " {}", + format_leaf_ref(&self.node(node_id).path, leaf_name) + )) + .unwrap_or_default() + ), + hook_id: self.hooks.last_key_value().map(|(hook_id, _)| *hook_id), + }) + } + + /// Sends more hook data from the root side. + pub fn send_root_hook_data( + &mut self, + hook_id: u64, + text: &str, + end_hook: bool, + ) -> Result { + let snapshot = self + .hooks + .get(&hook_id) + .cloned() + .ok_or(SimError::UnknownHook(hook_id))?; + let frame = self.nodes[self.root_id.0] + .endpoint + .make_data( + snapshot.peer_path.clone(), + hook_id, + snapshot.procedure_id.clone(), + text.as_bytes().to_vec(), + end_hook, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.record_trace( + self.root_id, + format!( + "root queued hook data for {}: {text}", + format_hook_ref(self.node(self.root_id).path.as_slice(), hook_id) + ), + ); + self.process_local_frame(self.root_id, frame)?; + Ok(ActionResult { + label: format!("Send hook data {hook_id}"), + hook_id: Some(hook_id), + }) + } + + /// Injects intentionally invalid traffic to demonstrate `InvalidHookPeer`. + pub fn inject_invalid_peer_data( + &mut self, + from_node_id: NodeId, + to_node_id: NodeId, + hook_id: u64, + procedure_id: &str, + text: &str, + ) -> Result { + let from_path = self.tree.node(from_node_id).path.clone(); + let to_path = self.tree.node(to_node_id).path.clone(); + let header = PacketHeader { + packet_type: PacketType::Data, + src_path: from_path.clone(), + dst_path: to_path.clone(), + dst_leaf: None, + hook_id: Some(hook_id), + }; + let message = DataMessage { + procedure_id: procedure_id.to_owned(), + data: text.as_bytes().to_vec(), + end_hook: false, + }; + let frame = unshell::protocol::encode_packet(&header, &message) + .map_err(|error| SimError::Protocol(error.to_string()))?; + + self.record_trace( + from_node_id, + format!( + "injected invalid peer data toward {} for {}", + format_path(&to_path), + format_hook_ref(self.node(to_node_id).path.as_slice(), hook_id) + ), + ); + self.process_local_frame(from_node_id, frame)?; + Ok(ActionResult { + label: format!( + "Inject invalid peer data for {}", + format_hook_ref(self.node(to_node_id).path.as_slice(), hook_id) + ), + hook_id: Some(hook_id), + }) + } +} diff --git a/treetest/src/sim/actions/driver.rs b/treetest/src/sim/actions/driver.rs new file mode 100644 index 0000000..c2bf5ba --- /dev/null +++ b/treetest/src/sim/actions/driver.rs @@ -0,0 +1,62 @@ +//! Simulator stepping helpers. + +use crossbeam_channel::TryRecvError; +use unshell::protocol::decode_frame; +use unshell::protocol::tree::Endpoint; + +use crate::model::NodeId; + +use super::super::types::{SimError, Simulation}; + +impl Simulation { + /// Processes one queued frame if available. + pub fn step(&mut self) -> Result { + for node_id in 0..self.nodes.len() { + match self.nodes[node_id].rx.try_recv() { + Ok(envelope) => { + self.record_trace( + NodeId(node_id), + format!("received frame via {:?}", envelope.ingress), + ); + let outcome = self.nodes[node_id] + .endpoint + .receive(&envelope.ingress, envelope.frame) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.process_outcome(NodeId(node_id), outcome)?; + return Ok(true); + } + Err(TryRecvError::Disconnected) => { + return Err(SimError::Protocol("mailbox disconnected".to_owned())); + } + Err(TryRecvError::Empty) => {} + } + } + Ok(false) + } + + /// Runs frames until the network becomes idle. + pub fn drain(&mut self) -> Result { + let mut steps = 0; + while self.step()? { + steps += 1; + } + Ok(steps) + } + + /// Returns a compact description of a frame for debugging. + pub fn describe_frame(frame: &[u8]) -> String { + match decode_frame(frame) { + Ok(parsed) => { + let header = parsed.header(); + format!( + "{:?} {} -> {} hook {:?}", + header.packet_type, + crate::model::format_path(&header.src_path), + crate::model::format_path(&header.dst_path), + header.hook_id, + ) + } + Err(error) => format!(""), + } + } +} diff --git a/treetest/src/sim/actions/queries.rs b/treetest/src/sim/actions/queries.rs new file mode 100644 index 0000000..5c77820 --- /dev/null +++ b/treetest/src/sim/actions/queries.rs @@ -0,0 +1,53 @@ +//! Read-only simulator queries used by tests and UI widgets. + +use crate::model::Selection; + +use unshell::protocol::FaultMessage; + +use super::super::types::{RecordedEvent, Simulation}; + +impl Simulation { + /// Returns the latest fault observed at the root, if any. + pub fn latest_root_fault(&self) -> Option<&FaultMessage> { + self.recorded_events + .iter() + .rev() + .find_map(|event| match event { + RecordedEvent::Fault { + node_path, message, .. + } if node_path == "/" => Some(message), + _ => None, + }) + } + + /// Returns the latest root data message as utf-8 for tests and status text. + pub fn latest_root_data_text(&self) -> Option { + self.recorded_events + .iter() + .rev() + .find_map(|event| match event { + RecordedEvent::Data { + node_path, message, .. + } if node_path == "/" => Some(String::from_utf8_lossy(&message.data).to_string()), + _ => None, + }) + } + + /// Returns all hook ids known to the demo in ascending order. + pub fn hook_ids(&self) -> Vec { + self.hooks.keys().copied().collect() + } + + /// Builds a human-readable description of the current selection. + pub fn selection_summary(&self, selection: &Selection) -> String { + match selection { + Selection::Node(node_id) => { + let node = self.node(*node_id); + format!("{}: {}", node.display_path(), node.title) + } + Selection::Leaf { node_id, leaf_name } => { + crate::model::format_leaf_ref(&self.node(*node_id).path, leaf_name) + } + } + } +} diff --git a/treetest/src/sim/runtime.rs b/treetest/src/sim/runtime.rs index badd366..36b1d23 100644 --- a/treetest/src/sim/runtime.rs +++ b/treetest/src/sim/runtime.rs @@ -1,453 +1,5 @@ -//! Internal packet routing and local event handling. -//! -//! This module is where the simulated transport meets the real protocol -//! endpoint runtime. It keeps forwarding logic, local delivery, and root -//! knowledge learning separate from the user-facing action helpers. +//! Runtime entry point for simulator internals. -use unshell::protocol::tree::{Endpoint, Ingress, LocalEvent, RouteDecision}; -use unshell::protocol::{ - CallMessage, DataMessage, FrameBytes, PacketHeader, deserialize_archived_bytes, -}; - -use crate::model::{ - EndpointProcedureKind, EndpointProcedureSpec, NodeId, format_hook_ref, format_leaf_ref, - format_path, -}; - -use super::types::{Envelope, HookSnapshot, RecordedEvent, SimError, Simulation}; - -impl Simulation { - pub(super) fn dispatch_root_call( - &mut self, - dst_path: Vec, - dst_leaf: Option, - procedure_id: &str, - data: Vec, - ) -> Result<(), SimError> { - let hook_id = self.nodes[self.root_id.0].endpoint.allocate_hook_id(); - let frame = self.nodes[self.root_id.0] - .endpoint - .make_call( - dst_path.clone(), - dst_leaf.clone(), - procedure_id.to_owned(), - Some(hook_id), - data, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.hooks.insert( - hook_id, - HookSnapshot { - hook_id, - host_path: Vec::new(), - peer_path: dst_path.clone(), - procedure_id: procedure_id.to_owned(), - target_leaf: dst_leaf.clone(), - closed: false, - last_message: format!("created for {}", format_path(&dst_path)), - }, - ); - self.record_trace( - self.root_id, - format!( - "root queued Call {} toward {}{}", - if procedure_id.is_empty() { - "" - } else { - procedure_id - }, - format_path(&dst_path), - dst_leaf - .as_ref() - .map(|leaf| format!(" {}", format_leaf_ref(&dst_path, leaf))) - .unwrap_or_default() - ), - ); - self.process_local_frame(self.root_id, frame) - } - - pub(super) fn process_local_frame( - &mut self, - node_id: NodeId, - frame: FrameBytes, - ) -> Result<(), SimError> { - let outcome = self.nodes[node_id.0] - .endpoint - .receive(&Ingress::Local, frame) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.process_outcome(node_id, outcome) - } - - pub(super) fn process_outcome( - &mut self, - node_id: NodeId, - outcome: unshell::protocol::tree::EndpointOutcome, - ) -> Result<(), SimError> { - if outcome.dropped { - self.record_trace(node_id, "packet dropped".to_owned()); - } - - for (route, frame) in outcome.forwards { - match route { - RouteDecision::Child(index) => { - let child_id = self.nodes[node_id.0] - .children - .get(index) - .copied() - .ok_or_else(|| { - SimError::Protocol(format!("missing child index {index}")) - })?; - self.record_trace( - node_id, - format!( - "forwarded frame to child {}", - self.node(child_id).display_path() - ), - ); - self.nodes[child_id.0] - .tx - .send(Envelope { - ingress: Ingress::Parent, - frame, - }) - .map_err(|error| SimError::Protocol(error.to_string()))?; - } - RouteDecision::Parent => { - let parent_id = self.nodes[node_id.0] - .parent - .ok_or_else(|| SimError::Protocol("missing parent route".to_owned()))?; - let child_path = self.node(node_id).path.clone(); - self.record_trace( - node_id, - format!( - "forwarded frame to parent {}", - self.node(parent_id).display_path() - ), - ); - self.nodes[parent_id.0] - .tx - .send(Envelope { - ingress: Ingress::Child(child_path), - frame, - }) - .map_err(|error| SimError::Protocol(error.to_string()))?; - } - RouteDecision::Local => { - return Err(SimError::Protocol( - "local route leaked into forward list".to_owned(), - )); - } - RouteDecision::Drop => { - self.record_trace(node_id, "route decision dropped frame".to_owned()); - } - } - } - - for event in outcome.events { - self.handle_local_event(node_id, event)?; - } - - Ok(()) - } - - fn handle_local_event(&mut self, node_id: NodeId, event: LocalEvent) -> Result<(), SimError> { - let node_path = self.node(node_id).display_path(); - match event { - LocalEvent::Data { header, message } => { - let text = String::from_utf8_lossy(&message.data).to_string(); - self.record_trace( - node_id, - format!( - "local Data on {}: {text}", - format_hook_ref( - self.node(node_id).path.as_slice(), - header.hook_id.unwrap_or(0) - ) - ), - ); - if let Some(hook_id) = header.hook_id { - if let Some(snapshot) = self.hooks.get_mut(&hook_id) { - snapshot.last_message = if text.is_empty() { - format!("binary payload ({} bytes)", message.data.len()) - } else { - text.clone() - }; - if message.end_hook { - snapshot.closed = true; - } - } - - if node_id == self.root_id { - self.learn_from_root_data(hook_id, &message); - } - } - - if let Some(session) = self - .chat_sessions - .get(&header.hook_id.unwrap_or(0)) - .cloned() - .filter(|session| session.node_id == node_id) - { - // Rationale: chat responses are implemented here instead of in the - // core endpoint so the protocol crate stays generic. The simulator - // acts as the application layer sitting above validated hook traffic. - let reply = if text.eq_ignore_ascii_case("bye") { - Some(("chat session closed".to_owned(), true)) - } else if !text.is_empty() { - Some((format!("chat ack: {}", text.to_uppercase()), false)) - } else { - None - }; - - if let Some((reply, end_hook)) = reply { - let frame = self.nodes[session.node_id.0] - .endpoint - .make_data( - session.host_path.clone(), - session.hook_id, - session.procedure_id.clone(), - reply.clone().into_bytes(), - end_hook, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.record_trace(session.node_id, format!("chat handler sent: {reply}")); - self.process_local_frame(session.node_id, frame)?; - if end_hook { - self.chat_sessions.remove(&session.hook_id); - } - } - } - - self.recorded_events.push(RecordedEvent::Data { - node_path, - header, - message, - }); - } - LocalEvent::Fault { header, message } => { - self.record_trace( - node_id, - format!( - "local Fault on {}: 0x{:02X}", - format_hook_ref( - self.node(node_id).path.as_slice(), - header.hook_id.unwrap_or(0) - ), - message.fault.0 - ), - ); - if let Some(hook_id) = header.hook_id { - if let Some(snapshot) = self.hooks.get_mut(&hook_id) { - snapshot.closed = true; - snapshot.last_message = format!("fault 0x{:02X}", message.fault.0); - } - self.chat_sessions.remove(&hook_id); - } - self.recorded_events.push(RecordedEvent::Fault { - node_path, - header, - message, - }); - } - LocalEvent::Call { header, message } => { - self.record_trace( - node_id, - format!( - "local Call {} on {}", - message.procedure_id, - header - .dst_leaf - .as_ref() - .map(|leaf| format_leaf_ref(&header.dst_path, leaf)) - .unwrap_or_else(|| "endpoint".to_owned()) - ), - ); - self.handle_application_call(node_id, &header, &message)?; - self.recorded_events.push(RecordedEvent::Call { - node_path, - header, - message, - }); - } - } - Ok(()) - } - - fn handle_application_call( - &mut self, - node_id: NodeId, - _header: &PacketHeader, - message: &CallMessage, - ) -> Result<(), SimError> { - let Some(hook) = &message.response_hook else { - return Ok(()); - }; - - let procedure = self - .lookup_endpoint_procedure(node_id, &message.procedure_id)? - .clone(); - match procedure.kind { - EndpointProcedureKind::Ping => { - let reply = format!("pong from {}", self.node(node_id).display_path()); - let frame = self.nodes[node_id.0] - .endpoint - .make_data( - hook.return_path.clone(), - hook.hook_id, - procedure.procedure_id.clone(), - reply.clone().into_bytes(), - true, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.record_trace(node_id, format!("endpoint sent ping reply: {reply}")); - self.process_local_frame(node_id, frame)?; - } - EndpointProcedureKind::ChunkedGreeting => { - for (index, text) in [ - "chunk 1: hello from the endpoint", - "chunk 2: routing stayed path-based", - "chunk 3: hook complete", - ] - .iter() - .enumerate() - { - let frame = self.nodes[node_id.0] - .endpoint - .make_data( - hook.return_path.clone(), - hook.hook_id, - procedure.procedure_id.clone(), - text.as_bytes().to_vec(), - index == 2, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1)); - self.process_local_frame(node_id, frame)?; - } - } - EndpointProcedureKind::Chat => { - self.chat_sessions.insert( - hook.hook_id, - super::types::ChatSession { - node_id, - hook_id: hook.hook_id, - host_path: hook.return_path.clone(), - procedure_id: procedure.procedure_id.clone(), - }, - ); - let frame = self.nodes[node_id.0] - .endpoint - .make_data( - hook.return_path.clone(), - hook.hook_id, - procedure.procedure_id.clone(), - b"chat ready".to_vec(), - false, - ) - .map_err(|error| SimError::Protocol(error.to_string()))?; - self.record_trace(node_id, "chat handler opened session".to_owned()); - self.process_local_frame(node_id, frame)?; - } - } - Ok(()) - } - - fn lookup_endpoint_procedure( - &self, - node_id: NodeId, - procedure_id: &str, - ) -> Result<&EndpointProcedureSpec, SimError> { - self.node(node_id) - .endpoint_procedures - .iter() - .find(|procedure| procedure.procedure_id == procedure_id) - .ok_or_else(|| SimError::UnknownProcedure { - node_path: self.node(node_id).display_path(), - procedure_id: procedure_id.to_owned(), - }) - } - - pub(super) fn require_leaf( - &self, - node_id: NodeId, - leaf_name: &str, - ) -> Result<&crate::model::LeafSpec, SimError> { - self.node(node_id) - .leaves - .iter() - .find(|leaf| leaf.name == leaf_name) - .ok_or_else(|| SimError::UnknownLeaf { - node_path: self.node(node_id).display_path(), - leaf_name: leaf_name.to_owned(), - }) - } - - pub(super) fn require_endpoint_procedure( - &self, - node_id: NodeId, - procedure_id: &str, - ) -> Result<(), SimError> { - self.lookup_endpoint_procedure(node_id, procedure_id) - .map(|_| ()) - } - - pub(super) fn record_trace(&mut self, node_id: NodeId, summary: String) { - let node_path = self.node(node_id).display_path(); - self.trace.push_back(super::types::TraceEvent { - tick: self.next_tick, - node_path, - summary, - }); - self.next_tick += 1; - while self.trace.len() > 200 { - self.trace.pop_front(); - } - } - - fn learn_from_root_data(&mut self, hook_id: u64, message: &DataMessage) { - let Some(snapshot) = self.hooks.get(&hook_id).cloned() else { - return; - }; - let Some(node_id) = self.tree.find_by_path(&snapshot.peer_path) else { - return; - }; - let demo_node = self.node(node_id).clone(); - - if snapshot.procedure_id.is_empty() { - if snapshot.target_leaf.is_some() { - if let Ok(introspection) = deserialize_archived_bytes::< - unshell::protocol::introspection::ArchivedLeafIntrospection, - unshell::protocol::LeafIntrospection, - >(&message.data) - { - self.root_knowledge - .remember_leaf_introspection(&demo_node, &introspection); - } - } else if let Ok(introspection) = deserialize_archived_bytes::< - unshell::protocol::introspection::ArchivedEndpointIntrospection, - unshell::protocol::EndpointIntrospection, - >(&message.data) - { - self.root_knowledge - .remember_endpoint_introspection(&demo_node, &introspection); - } - return; - } - - if let Some(procedure) = demo_node - .endpoint_procedures - .iter() - .find(|procedure| procedure.procedure_id == snapshot.procedure_id) - { - self.root_knowledge - .remember_endpoint_procedure(&demo_node, procedure); - } - - if let Some(leaf_name) = &snapshot.target_leaf - && let Some(leaf_spec) = demo_node.leaves.iter().find(|leaf| &leaf.name == leaf_name) - { - self.root_knowledge - .remember_leaf_from_spec(&demo_node, leaf_spec); - } - } -} +mod dispatch; +mod events; +mod learning; diff --git a/treetest/src/sim/runtime/dispatch.rs b/treetest/src/sim/runtime/dispatch.rs new file mode 100644 index 0000000..556b8ee --- /dev/null +++ b/treetest/src/sim/runtime/dispatch.rs @@ -0,0 +1,156 @@ +//! Packet dispatch and routing glue. + +use unshell::protocol::FrameBytes; +use unshell::protocol::tree::{Endpoint, Ingress, RouteDecision}; + +use crate::model::{NodeId, format_leaf_ref, format_path}; + +use super::super::types::{Envelope, HookSnapshot, SimError, Simulation, TraceEvent}; + +impl Simulation { + pub(crate) fn dispatch_root_call( + &mut self, + dst_path: Vec, + dst_leaf: Option, + procedure_id: &str, + data: Vec, + ) -> Result<(), SimError> { + let hook_id = self.nodes[self.root_id.0].endpoint.allocate_hook_id(); + let frame = self.nodes[self.root_id.0] + .endpoint + .make_call( + dst_path.clone(), + dst_leaf.clone(), + procedure_id.to_owned(), + Some(hook_id), + data, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.hooks.insert( + hook_id, + HookSnapshot { + hook_id, + host_path: Vec::new(), + peer_path: dst_path.clone(), + procedure_id: procedure_id.to_owned(), + target_leaf: dst_leaf.clone(), + closed: false, + last_message: format!("created for {}", format_path(&dst_path)), + }, + ); + self.record_trace( + self.root_id, + format!( + "root queued Call {} toward {}{}", + if procedure_id.is_empty() { + "" + } else { + procedure_id + }, + format_path(&dst_path), + dst_leaf + .as_ref() + .map(|leaf| format!(" {}", format_leaf_ref(&dst_path, leaf))) + .unwrap_or_default() + ), + ); + self.process_local_frame(self.root_id, frame) + } + + pub(crate) fn process_local_frame( + &mut self, + node_id: NodeId, + frame: FrameBytes, + ) -> Result<(), SimError> { + let outcome = self.nodes[node_id.0] + .endpoint + .receive(&Ingress::Local, frame) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.process_outcome(node_id, outcome) + } + + pub(crate) fn process_outcome( + &mut self, + node_id: NodeId, + outcome: unshell::protocol::tree::EndpointOutcome, + ) -> Result<(), SimError> { + if outcome.dropped { + self.record_trace(node_id, "packet dropped".to_owned()); + } + + for (route, frame) in outcome.forwards { + match route { + RouteDecision::Child(index) => { + let child_id = self.nodes[node_id.0] + .children + .get(index) + .copied() + .ok_or_else(|| { + SimError::Protocol(format!("missing child index {index}")) + })?; + self.record_trace( + node_id, + format!( + "forwarded frame to child {}", + self.node(child_id).display_path() + ), + ); + self.nodes[child_id.0] + .tx + .send(Envelope { + ingress: Ingress::Parent, + frame, + }) + .map_err(|error| SimError::Protocol(error.to_string()))?; + } + RouteDecision::Parent => { + let parent_id = self.nodes[node_id.0] + .parent + .ok_or_else(|| SimError::Protocol("missing parent route".to_owned()))?; + let child_path = self.node(node_id).path.clone(); + self.record_trace( + node_id, + format!( + "forwarded frame to parent {}", + self.node(parent_id).display_path() + ), + ); + self.nodes[parent_id.0] + .tx + .send(Envelope { + ingress: Ingress::Child(child_path), + frame, + }) + .map_err(|error| SimError::Protocol(error.to_string()))?; + } + RouteDecision::Local => { + return Err(SimError::Protocol( + "local route leaked into forward list".to_owned(), + )); + } + RouteDecision::Drop => { + self.record_trace(node_id, "route decision dropped frame".to_owned()); + } + } + } + + for event in outcome.events { + self.handle_local_event(node_id, event)?; + } + + Ok(()) + } + + pub(crate) fn record_trace(&mut self, node_id: NodeId, summary: String) { + let node_path = self.node(node_id).display_path(); + self.trace.push_back(TraceEvent { + tick: self.next_tick, + node_path, + summary, + }); + self.next_tick += 1; + while self.trace.len() > 200 { + self.trace.pop_front(); + } + } +} diff --git a/treetest/src/sim/runtime/events.rs b/treetest/src/sim/runtime/events.rs new file mode 100644 index 0000000..2410408 --- /dev/null +++ b/treetest/src/sim/runtime/events.rs @@ -0,0 +1,4 @@ +//! Local event handling entry point. + +mod application; +mod local; diff --git a/treetest/src/sim/runtime/events/application.rs b/treetest/src/sim/runtime/events/application.rs new file mode 100644 index 0000000..ef95e5b --- /dev/null +++ b/treetest/src/sim/runtime/events/application.rs @@ -0,0 +1,127 @@ +//! Application-procedure handling layered over protocol calls. + +use unshell::protocol::{CallMessage, PacketHeader}; + +use crate::model::{EndpointProcedureKind, EndpointProcedureSpec, NodeId}; + +use super::super::super::types::{SimError, Simulation}; + +impl Simulation { + pub(super) fn handle_application_call( + &mut self, + node_id: NodeId, + _header: &PacketHeader, + message: &CallMessage, + ) -> Result<(), SimError> { + let Some(hook) = &message.response_hook else { + return Ok(()); + }; + + let procedure = self + .lookup_endpoint_procedure(node_id, &message.procedure_id)? + .clone(); + match procedure.kind { + EndpointProcedureKind::Ping => { + let reply = format!("pong from {}", self.node(node_id).display_path()); + let frame = self.nodes[node_id.0] + .endpoint + .make_data( + hook.return_path.clone(), + hook.hook_id, + procedure.procedure_id.clone(), + reply.clone().into_bytes(), + true, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.record_trace(node_id, format!("endpoint sent ping reply: {reply}")); + self.process_local_frame(node_id, frame)?; + } + EndpointProcedureKind::ChunkedGreeting => { + for (index, text) in [ + "chunk 1: hello from the endpoint", + "chunk 2: routing stayed path-based", + "chunk 3: hook complete", + ] + .iter() + .enumerate() + { + let frame = self.nodes[node_id.0] + .endpoint + .make_data( + hook.return_path.clone(), + hook.hook_id, + procedure.procedure_id.clone(), + text.as_bytes().to_vec(), + index == 2, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1)); + self.process_local_frame(node_id, frame)?; + } + } + EndpointProcedureKind::Chat => { + self.chat_sessions.insert( + hook.hook_id, + super::super::super::types::ChatSession { + node_id, + hook_id: hook.hook_id, + host_path: hook.return_path.clone(), + procedure_id: procedure.procedure_id.clone(), + }, + ); + let frame = self.nodes[node_id.0] + .endpoint + .make_data( + hook.return_path.clone(), + hook.hook_id, + procedure.procedure_id.clone(), + b"chat ready".to_vec(), + false, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.record_trace(node_id, "chat handler opened session".to_owned()); + self.process_local_frame(node_id, frame)?; + } + } + Ok(()) + } + + pub(super) fn lookup_endpoint_procedure( + &self, + node_id: NodeId, + procedure_id: &str, + ) -> Result<&EndpointProcedureSpec, SimError> { + self.node(node_id) + .endpoint_procedures + .iter() + .find(|procedure| procedure.procedure_id == procedure_id) + .ok_or_else(|| SimError::UnknownProcedure { + node_path: self.node(node_id).display_path(), + procedure_id: procedure_id.to_owned(), + }) + } + + pub(crate) fn require_leaf( + &self, + node_id: NodeId, + leaf_name: &str, + ) -> Result<&crate::model::LeafSpec, SimError> { + self.node(node_id) + .leaves + .iter() + .find(|leaf| leaf.name == leaf_name) + .ok_or_else(|| SimError::UnknownLeaf { + node_path: self.node(node_id).display_path(), + leaf_name: leaf_name.to_owned(), + }) + } + + pub(crate) fn require_endpoint_procedure( + &self, + node_id: NodeId, + procedure_id: &str, + ) -> Result<(), SimError> { + self.lookup_endpoint_procedure(node_id, procedure_id) + .map(|_| ()) + } +} diff --git a/treetest/src/sim/runtime/events/local.rs b/treetest/src/sim/runtime/events/local.rs new file mode 100644 index 0000000..4009db3 --- /dev/null +++ b/treetest/src/sim/runtime/events/local.rs @@ -0,0 +1,136 @@ +//! Protocol local-event handling. + +use unshell::protocol::tree::LocalEvent; + +use crate::model::{NodeId, format_hook_ref, format_leaf_ref}; + +use super::super::super::types::{RecordedEvent, SimError, Simulation}; + +impl Simulation { + pub(crate) fn handle_local_event( + &mut self, + node_id: NodeId, + event: LocalEvent, + ) -> Result<(), SimError> { + let node_path = self.node(node_id).display_path(); + match event { + LocalEvent::Data { header, message } => { + let text = String::from_utf8_lossy(&message.data).to_string(); + self.record_trace( + node_id, + format!( + "local Data on {}: {text}", + format_hook_ref( + self.node(node_id).path.as_slice(), + header.hook_id.unwrap_or(0) + ) + ), + ); + if let Some(hook_id) = header.hook_id { + if let Some(snapshot) = self.hooks.get_mut(&hook_id) { + snapshot.last_message = if text.is_empty() { + format!("binary payload ({} bytes)", message.data.len()) + } else { + text.clone() + }; + if message.end_hook { + snapshot.closed = true; + } + } + + if node_id == self.root_id { + self.learn_from_root_data(hook_id, &message); + } + } + + if let Some(session) = self + .chat_sessions + .get(&header.hook_id.unwrap_or(0)) + .cloned() + .filter(|session| session.node_id == node_id) + { + // Rationale: chat responses are implemented here instead of in the + // core endpoint so the protocol crate stays generic. The simulator + // acts as the application layer sitting above validated hook traffic. + let reply = if text.eq_ignore_ascii_case("bye") { + Some(("chat session closed".to_owned(), true)) + } else if !text.is_empty() { + Some((format!("chat ack: {}", text.to_uppercase()), false)) + } else { + None + }; + + if let Some((reply, end_hook)) = reply { + let frame = self.nodes[session.node_id.0] + .endpoint + .make_data( + session.host_path.clone(), + session.hook_id, + session.procedure_id.clone(), + reply.clone().into_bytes(), + end_hook, + ) + .map_err(|error| SimError::Protocol(error.to_string()))?; + self.record_trace(session.node_id, format!("chat handler sent: {reply}")); + self.process_local_frame(session.node_id, frame)?; + if end_hook { + self.chat_sessions.remove(&session.hook_id); + } + } + } + + self.recorded_events.push(RecordedEvent::Data { + node_path, + header, + message, + }); + } + LocalEvent::Fault { header, message } => { + self.record_trace( + node_id, + format!( + "local Fault on {}: 0x{:02X}", + format_hook_ref( + self.node(node_id).path.as_slice(), + header.hook_id.unwrap_or(0) + ), + message.fault.0 + ), + ); + if let Some(hook_id) = header.hook_id { + if let Some(snapshot) = self.hooks.get_mut(&hook_id) { + snapshot.closed = true; + snapshot.last_message = format!("fault 0x{:02X}", message.fault.0); + } + self.chat_sessions.remove(&hook_id); + } + self.recorded_events.push(RecordedEvent::Fault { + node_path, + header, + message, + }); + } + LocalEvent::Call { header, message } => { + self.record_trace( + node_id, + format!( + "local Call {} on {}", + message.procedure_id, + header + .dst_leaf + .as_ref() + .map(|leaf| format_leaf_ref(&header.dst_path, leaf)) + .unwrap_or_else(|| "endpoint".to_owned()) + ), + ); + self.handle_application_call(node_id, &header, &message)?; + self.recorded_events.push(RecordedEvent::Call { + node_path, + header, + message, + }); + } + } + Ok(()) + } +} diff --git a/treetest/src/sim/runtime/learning.rs b/treetest/src/sim/runtime/learning.rs new file mode 100644 index 0000000..bcf76a5 --- /dev/null +++ b/treetest/src/sim/runtime/learning.rs @@ -0,0 +1,56 @@ +//! Root-side knowledge learning from returned data. + +use unshell::protocol::{ + DataMessage, EndpointIntrospection, LeafIntrospection, deserialize_archived_bytes, +}; + +use super::super::types::Simulation; + +impl Simulation { + pub(crate) fn learn_from_root_data(&mut self, hook_id: u64, message: &DataMessage) { + let Some(snapshot) = self.hooks.get(&hook_id).cloned() else { + return; + }; + let Some(node_id) = self.tree.find_by_path(&snapshot.peer_path) else { + return; + }; + let demo_node = self.node(node_id).clone(); + + if snapshot.procedure_id.is_empty() { + if snapshot.target_leaf.is_some() { + if let Ok(introspection) = deserialize_archived_bytes::< + unshell::protocol::introspection::ArchivedLeafIntrospection, + LeafIntrospection, + >(&message.data) + { + self.root_knowledge + .remember_leaf_introspection(&demo_node, &introspection); + } + } else if let Ok(introspection) = deserialize_archived_bytes::< + unshell::protocol::introspection::ArchivedEndpointIntrospection, + EndpointIntrospection, + >(&message.data) + { + self.root_knowledge + .remember_endpoint_introspection(&demo_node, &introspection); + } + return; + } + + if let Some(procedure) = demo_node + .endpoint_procedures + .iter() + .find(|procedure| procedure.procedure_id == snapshot.procedure_id) + { + self.root_knowledge + .remember_endpoint_procedure(&demo_node, procedure); + } + + if let Some(leaf_name) = &snapshot.target_leaf + && let Some(leaf_spec) = demo_node.leaves.iter().find(|leaf| &leaf.name == leaf_name) + { + self.root_knowledge + .remember_leaf_from_spec(&demo_node, leaf_spec); + } + } +}