mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Improve Rust code clarity across the workspace
Document public APIs and non-obvious control flow so the protocol, simulator, and macro crates are easier to follow. Tighten a few helper paths and feature gates while preserving behavior and keeping the workspace warning-free.
This commit is contained in:
+123
-120
@@ -8,6 +8,19 @@
|
||||
use super::{App, AppError, NodeId, Selection};
|
||||
|
||||
impl App {
|
||||
/// Drains queued simulator work, refreshes the visible selection list, and
|
||||
/// reports the action result in the footer.
|
||||
fn finish_action(
|
||||
&mut self,
|
||||
preferred_node: Option<NodeId>,
|
||||
label: impl Into<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(preferred_node);
|
||||
self.status = format!("{} ({steps} steps)", label.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs protocol introspection for the current selection.
|
||||
///
|
||||
/// Rationale: node and leaf introspection share one key because the protocol
|
||||
@@ -18,18 +31,14 @@ impl App {
|
||||
// Route the blank procedure to endpoint-wide introspection.
|
||||
let result = self.simulation.call_endpoint_introspection(node_id)?;
|
||||
// Drain immediately so the inspector reflects the learned state.
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
}
|
||||
Selection::Leaf { node_id, leaf_name } => {
|
||||
// Route the blank procedure to one specific leaf.
|
||||
let result = self
|
||||
.simulation
|
||||
.call_leaf_introspection(node_id, &leaf_name)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -40,102 +49,99 @@ impl App {
|
||||
/// Rationale: the payload is fixed so the demo highlights packet flow rather
|
||||
/// than turning the TUI into a line editor.
|
||||
pub(super) fn perform_echo(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Leaf { node_id, leaf_name } = self.selected().clone() {
|
||||
let result =
|
||||
self.simulation
|
||||
.call_echo_leaf(node_id, &leaf_name, "demo echo from root")?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
let Selection::Leaf { node_id, leaf_name } = self.selected().clone() else {
|
||||
self.status = "Select a leaf first, then press e.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = self
|
||||
.simulation
|
||||
.call_echo_leaf(node_id, &leaf_name, "demo echo from root")?;
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls the first endpoint-level procedure on the selected node.
|
||||
pub(super) fn perform_ping(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.first()
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"ping".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no endpoint procedures.".to_owned();
|
||||
}
|
||||
} else {
|
||||
let Selection::Node(node_id) = self.selected().clone() else {
|
||||
self.status = "Select a node first, then press p.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.first()
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
else {
|
||||
self.status = "Selected node has no endpoint procedures.".to_owned();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result =
|
||||
self.simulation
|
||||
.call_endpoint_procedure(node_id, &procedure_id, b"ping".to_vec())?;
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls the chunked-response procedure on the selected node.
|
||||
pub(super) fn perform_chunked(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| {
|
||||
procedure.description.contains("chunk")
|
||||
|| procedure.procedure_id.contains("chunked")
|
||||
})
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"chunk please".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no chunked procedure.".to_owned();
|
||||
}
|
||||
} else {
|
||||
let Selection::Node(node_id) = self.selected().clone() else {
|
||||
self.status = "Select a node first, then press c.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| {
|
||||
procedure.description.contains("chunk")
|
||||
|| procedure.procedure_id.contains("chunked")
|
||||
})
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
else {
|
||||
self.status = "Selected node has no chunked procedure.".to_owned();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"chunk please".to_vec(),
|
||||
)?;
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a long-lived chat hook on the selected node.
|
||||
pub(super) fn perform_chat_call(&mut self) -> Result<(), AppError> {
|
||||
if let Selection::Node(node_id) = self.selected().clone() {
|
||||
if let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| procedure.procedure_id.contains("chat"))
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
{
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"open chat".to_vec(),
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(Some(node_id));
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status = "Selected node has no chat procedure.".to_owned();
|
||||
}
|
||||
} else {
|
||||
let Selection::Node(node_id) = self.selected().clone() else {
|
||||
self.status = "Select a node first, then press h.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(procedure_id) = self
|
||||
.simulation
|
||||
.node(node_id)
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.find(|procedure| procedure.procedure_id.contains("chat"))
|
||||
.map(|procedure| procedure.procedure_id.clone())
|
||||
else {
|
||||
self.status = "Selected node has no chat procedure.".to_owned();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = self.simulation.call_endpoint_procedure(
|
||||
node_id,
|
||||
&procedure_id,
|
||||
b"open chat".to_vec(),
|
||||
)?;
|
||||
self.finish_action(Some(node_id), result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -144,57 +150,54 @@ impl App {
|
||||
/// Rationale: using the latest hook keeps the demo simple while still
|
||||
/// exposing bidirectional hook behavior.
|
||||
pub(super) fn perform_chat_data(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
let result =
|
||||
self.simulation
|
||||
.send_root_hook_data(hook_id, "hello from the root", false)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(None);
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = self
|
||||
.simulation
|
||||
.send_root_hook_data(hook_id, "hello from the root", false)?;
|
||||
self.finish_action(None, result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the newest known chat hook from the root side.
|
||||
pub(super) fn perform_chat_bye(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
let result = self.simulation.send_root_hook_data(hook_id, "bye", true)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(None);
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||
self.status = "No known hook yet. Press h to open chat first.".to_owned();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = self.simulation.send_root_hook_data(hook_id, "bye", true)?;
|
||||
self.finish_action(None, result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Injects intentionally invalid hook data to exercise fault handling.
|
||||
pub(super) fn perform_invalid_fault_demo(&mut self) -> Result<(), AppError> {
|
||||
if let Some(hook_id) = self.simulation.hook_ids().last().copied() {
|
||||
// The root is always node zero in every built-in scenario.
|
||||
let root_id = NodeId(0);
|
||||
if self.simulation.tree.nodes.len() > 1 {
|
||||
// The first child is enough to spoof a wrong peer path.
|
||||
let attacker = NodeId(1);
|
||||
let result = self.simulation.inject_invalid_peer_data(
|
||||
attacker,
|
||||
root_id,
|
||||
hook_id,
|
||||
"demo.endpoint.v1.chat.session",
|
||||
"spoofed data",
|
||||
)?;
|
||||
let steps = self.simulation.drain()?;
|
||||
self.refresh_selections(None);
|
||||
self.status = format!("{} ({steps} steps)", result.label);
|
||||
} else {
|
||||
self.status =
|
||||
"This scenario has no second node for invalid-peer traffic.".to_owned();
|
||||
}
|
||||
} else {
|
||||
let Some(hook_id) = self.simulation.hook_ids().last().copied() else {
|
||||
self.status = "Open a hook first before injecting invalid traffic.".to_owned();
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if self.simulation.tree.nodes.len() <= 1 {
|
||||
self.status = "This scenario has no second node for invalid-peer traffic.".to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The root is always node zero in every built-in scenario.
|
||||
let root_id = NodeId(0);
|
||||
// The first child is enough to spoof a wrong peer path.
|
||||
let attacker = NodeId(1);
|
||||
let result = self.simulation.inject_invalid_peer_data(
|
||||
attacker,
|
||||
root_id,
|
||||
hook_id,
|
||||
"demo.endpoint.v1.chat.session",
|
||||
"spoofed data",
|
||||
)?;
|
||||
self.finish_action(None, result.label)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ mod ui;
|
||||
use ratatui::DefaultTerminal;
|
||||
|
||||
use crate::{
|
||||
model::{NodeId, Selection},
|
||||
model::{NodeId, ScenarioDefinition, Selection},
|
||||
scenarios::built_in_scenarios,
|
||||
sim::Simulation,
|
||||
};
|
||||
@@ -19,8 +19,10 @@ use crate::{
|
||||
/// Errors returned by the TUI application.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// Terminal setup, teardown, or input/output failed.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The simulator rejected an operation or could not advance.
|
||||
#[error(transparent)]
|
||||
Sim(#[from] crate::sim::SimError),
|
||||
}
|
||||
@@ -32,7 +34,7 @@ pub fn run() -> Result<(), AppError> {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct App {
|
||||
scenarios: Vec<crate::model::ScenarioDefinition>,
|
||||
scenarios: Vec<ScenarioDefinition>,
|
||||
scenario_index: usize,
|
||||
simulation: Simulation,
|
||||
selection_index: usize,
|
||||
|
||||
@@ -68,11 +68,19 @@ impl App {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
|
||||
// Poll with a timeout so redraws stay responsive without busy-spinning.
|
||||
if event::poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind == KeyEventKind::Press
|
||||
&& !self.handle_key(key.code)?
|
||||
{
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.handle_key(key.code)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -176,14 +184,14 @@ impl App {
|
||||
/// so selection repair needs to happen in one dedicated place.
|
||||
pub(super) fn refresh_selections(&mut self, preferred_node: Option<NodeId>) {
|
||||
// Prefer an explicit node if the caller knows what should stay selected.
|
||||
let current = preferred_node.unwrap_or_else(|| self.selected().node_id());
|
||||
let selected_node_id = preferred_node.unwrap_or_else(|| self.selected().node_id());
|
||||
self.selections = ui::build_selections(&self.simulation);
|
||||
|
||||
// Fall back to the first row when the previous node disappeared.
|
||||
self.selection_index = self
|
||||
.selections
|
||||
.iter()
|
||||
.position(|selection| selection.node_id() == current)
|
||||
.position(|selection| selection.node_id() == selected_node_id)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ impl App {
|
||||
pub(crate) fn render(&self, frame: &mut Frame<'_>) {
|
||||
// Split the screen into a small header, a large working area, and a
|
||||
// persistent status/footer region.
|
||||
let chunks = Layout::default()
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
@@ -29,9 +29,9 @@ impl App {
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
self.render_header(frame, chunks[0]);
|
||||
self.render_body(frame, chunks[1]);
|
||||
self.render_footer(frame, chunks[2]);
|
||||
self.render_header(frame, rows[0]);
|
||||
self.render_body(frame, rows[1]);
|
||||
self.render_footer(frame, rows[2]);
|
||||
}
|
||||
|
||||
/// Renders the scenario header bar.
|
||||
|
||||
@@ -60,7 +60,7 @@ impl App {
|
||||
/// while ground-truth mode intentionally exposes the entire scenario tree.
|
||||
pub(crate) fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
||||
let mut selections = Vec::new();
|
||||
let node_ids: Vec<_> = match simulation.inspector_mode {
|
||||
let visible_node_ids: Vec<_> = match simulation.inspector_mode {
|
||||
InspectorMode::GroundTruth => simulation.tree.nodes.iter().map(|node| node.id).collect(),
|
||||
InspectorMode::Realistic => simulation
|
||||
.root_knowledge
|
||||
@@ -70,7 +70,7 @@ pub(crate) fn build_selections(simulation: &Simulation) -> Vec<Selection> {
|
||||
.collect(),
|
||||
};
|
||||
|
||||
for node_id in node_ids {
|
||||
for node_id in visible_node_ids {
|
||||
let node = simulation.node(node_id);
|
||||
selections.push(Selection::Node(node.id));
|
||||
match simulation.inspector_mode {
|
||||
|
||||
@@ -13,40 +13,56 @@ use crate::model::EndpointProcedureSpec;
|
||||
/// Root inspector mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InspectorMode {
|
||||
/// Render the full scenario definition, including information the root has
|
||||
/// not yet learned through traffic or introspection.
|
||||
GroundTruth,
|
||||
/// Render only the subset of state the root host could plausibly know.
|
||||
Realistic,
|
||||
}
|
||||
|
||||
/// Learned procedure metadata stored by the root host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LearnedProcedure {
|
||||
/// Stable protocol identifier for the learned procedure.
|
||||
pub procedure_id: String,
|
||||
/// Optional human-readable description learned from config or introspection.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Learned leaf metadata stored by the root host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LearnedLeaf {
|
||||
/// Leaf name relative to the endpoint path.
|
||||
pub leaf_name: String,
|
||||
/// Optional human-readable description for the leaf.
|
||||
pub description: Option<String>,
|
||||
/// Procedures currently known on the leaf.
|
||||
pub procedures: Vec<LearnedProcedure>,
|
||||
}
|
||||
|
||||
/// Learned endpoint metadata stored by the root host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LearnedNode {
|
||||
/// Absolute node path from the root.
|
||||
pub path: Vec<String>,
|
||||
/// Optional display title shown in the inspector.
|
||||
pub title: Option<String>,
|
||||
/// Optional endpoint description shown in the inspector.
|
||||
pub description: Option<String>,
|
||||
/// Whether the node is a direct child of the root.
|
||||
pub direct_child: bool,
|
||||
/// Endpoint-level procedures known on the node itself.
|
||||
pub endpoint_procedures: Vec<LearnedProcedure>,
|
||||
/// Leaf metadata currently known for the node.
|
||||
pub leaves: Vec<LearnedLeaf>,
|
||||
/// Whether endpoint introspection definitely ran against this node.
|
||||
pub endpoint_introspected: bool,
|
||||
}
|
||||
|
||||
/// Root-host knowledge accumulated from local configuration and observed traffic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RootKnowledge {
|
||||
/// Learned nodes keyed by their absolute path.
|
||||
pub nodes: BTreeMap<Vec<String>, LearnedNode>,
|
||||
}
|
||||
|
||||
@@ -58,49 +74,9 @@ impl RootKnowledge {
|
||||
};
|
||||
for node in &tree.nodes {
|
||||
if node.path.is_empty() || node.path.len() == 1 {
|
||||
// Realistic mode intentionally starts with root plus direct children,
|
||||
// not the full transitive tree.
|
||||
let direct_child = node.path.len() == 1;
|
||||
let mut learned = LearnedNode {
|
||||
path: node.path.clone(),
|
||||
title: Some(node.title.clone()),
|
||||
description: Some(node.description.clone()),
|
||||
direct_child,
|
||||
endpoint_procedures: Vec::new(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_introspected: node.path.is_empty(),
|
||||
};
|
||||
|
||||
if node.path.is_empty() {
|
||||
// The root always knows its own procedures and leaves because
|
||||
// those are locally configured, not discovered remotely.
|
||||
learned.endpoint_procedures = node
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.map(|procedure| LearnedProcedure {
|
||||
procedure_id: procedure.procedure_id.clone(),
|
||||
description: Some(procedure.description.clone()),
|
||||
})
|
||||
.collect();
|
||||
learned.leaves = node
|
||||
.leaves
|
||||
.iter()
|
||||
.map(|leaf| LearnedLeaf {
|
||||
leaf_name: leaf.name.clone(),
|
||||
description: Some(leaf.description.clone()),
|
||||
procedures: leaf
|
||||
.procedures
|
||||
.iter()
|
||||
.map(|procedure_id| LearnedProcedure {
|
||||
procedure_id: procedure_id.clone(),
|
||||
description: Some(leaf.description.clone()),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
knowledge.nodes.insert(node.path.clone(), learned);
|
||||
knowledge
|
||||
.nodes
|
||||
.insert(node.path.clone(), initial_learned_node(node));
|
||||
}
|
||||
}
|
||||
knowledge
|
||||
@@ -223,12 +199,56 @@ impl RootKnowledge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the root's initial record for one statically known node.
|
||||
fn initial_learned_node(node: &crate::model::DemoNode) -> LearnedNode {
|
||||
let mut learned = LearnedNode {
|
||||
path: node.path.clone(),
|
||||
title: Some(node.title.clone()),
|
||||
description: Some(node.description.clone()),
|
||||
direct_child: node.path.len() == 1,
|
||||
endpoint_procedures: Vec::new(),
|
||||
leaves: Vec::new(),
|
||||
endpoint_introspected: node.path.is_empty(),
|
||||
};
|
||||
|
||||
if node.path.is_empty() {
|
||||
// The root always knows its own procedures and leaves because those are
|
||||
// locally configured, not discovered remotely.
|
||||
learned.endpoint_procedures = node
|
||||
.endpoint_procedures
|
||||
.iter()
|
||||
.map(|procedure| LearnedProcedure {
|
||||
procedure_id: procedure.procedure_id.clone(),
|
||||
description: Some(procedure.description.clone()),
|
||||
})
|
||||
.collect();
|
||||
learned.leaves = node
|
||||
.leaves
|
||||
.iter()
|
||||
.map(|leaf| LearnedLeaf {
|
||||
leaf_name: leaf.name.clone(),
|
||||
description: Some(leaf.description.clone()),
|
||||
procedures: leaf
|
||||
.procedures
|
||||
.iter()
|
||||
.map(|procedure_id| LearnedProcedure {
|
||||
procedure_id: procedure_id.clone(),
|
||||
description: Some(leaf.description.clone()),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
learned
|
||||
}
|
||||
|
||||
/// Returns one learned leaf entry, creating it if necessary.
|
||||
fn ensure_leaf<'a>(
|
||||
leaves: &'a mut Vec<LearnedLeaf>,
|
||||
fn ensure_leaf(
|
||||
leaves: &mut Vec<LearnedLeaf>,
|
||||
leaf_name: String,
|
||||
description: Option<String>,
|
||||
) -> &'a mut LearnedLeaf {
|
||||
) -> &mut LearnedLeaf {
|
||||
if let Some(index) = leaves.iter().position(|leaf| leaf.leaf_name == leaf_name) {
|
||||
if leaves[index].description.is_none() {
|
||||
leaves[index].description = description;
|
||||
|
||||
@@ -32,16 +32,14 @@ impl Simulation {
|
||||
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()))?;
|
||||
let frame = self.make_endpoint_data_frame(
|
||||
node_id,
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
reply.clone().into_bytes(),
|
||||
true,
|
||||
)?;
|
||||
self.record_trace(node_id, format!("endpoint sent ping reply: {reply}"));
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
@@ -54,16 +52,14 @@ impl Simulation {
|
||||
.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()))?;
|
||||
let frame = self.make_endpoint_data_frame(
|
||||
node_id,
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
text.as_bytes().to_vec(),
|
||||
index == 2,
|
||||
)?;
|
||||
self.record_trace(node_id, format!("endpoint sent chunk {}", index + 1));
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
@@ -80,16 +76,14 @@ impl Simulation {
|
||||
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()))?;
|
||||
let frame = self.make_endpoint_data_frame(
|
||||
node_id,
|
||||
hook.return_path.clone(),
|
||||
hook.hook_id,
|
||||
procedure.procedure_id.clone(),
|
||||
b"chat ready".to_vec(),
|
||||
false,
|
||||
)?;
|
||||
self.record_trace(node_id, "chat handler opened session".to_owned());
|
||||
self.process_local_frame(node_id, frame)?;
|
||||
}
|
||||
@@ -97,6 +91,23 @@ impl Simulation {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds one endpoint-originated data frame after application logic decides
|
||||
/// what to send back on an already-validated hook.
|
||||
fn make_endpoint_data_frame(
|
||||
&mut self,
|
||||
node_id: NodeId,
|
||||
return_path: Vec<String>,
|
||||
hook_id: u64,
|
||||
procedure_id: String,
|
||||
data: Vec<u8>,
|
||||
end_hook: bool,
|
||||
) -> Result<unshell::protocol::FrameBytes, SimError> {
|
||||
self.nodes[node_id.0]
|
||||
.endpoint
|
||||
.make_data(return_path, hook_id, procedure_id, data, end_hook)
|
||||
.map_err(|error| SimError::Protocol(error.to_string()))
|
||||
}
|
||||
|
||||
/// Resolves one endpoint procedure from the ground-truth node metadata.
|
||||
pub(super) fn lookup_endpoint_procedure(
|
||||
&self,
|
||||
|
||||
@@ -20,51 +20,24 @@ impl Simulation {
|
||||
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)
|
||||
)
|
||||
),
|
||||
let hook_ref = format_hook_ref(
|
||||
self.node(node_id).path.as_slice(),
|
||||
header.hook_id.unwrap_or(0),
|
||||
);
|
||||
self.record_trace(node_id, format!("local Data on {hook_ref}: {text}"));
|
||||
|
||||
if let Some(hook_id) = header.hook_id {
|
||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||
// Keep the most recent human-readable payload in the UI.
|
||||
snapshot.last_message = if text.is_empty() {
|
||||
format!("binary payload ({} bytes)", message.data.len())
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
if message.end_hook {
|
||||
snapshot.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.update_hook_snapshot(hook_id, &text, message.data.len(), message.end_hook);
|
||||
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)
|
||||
{
|
||||
if let Some(session) = self.chat_session_for_event(node_id, header.hook_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
|
||||
};
|
||||
let reply = chat_reply_for_text(&text);
|
||||
|
||||
if let Some((reply, end_hook)) = reply {
|
||||
let frame = self.nodes[session.node_id.0]
|
||||
@@ -92,16 +65,13 @@ impl Simulation {
|
||||
});
|
||||
}
|
||||
LocalEvent::Fault { header, message } => {
|
||||
let hook_ref = format_hook_ref(
|
||||
self.node(node_id).path.as_slice(),
|
||||
header.hook_id.unwrap_or(0),
|
||||
);
|
||||
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
|
||||
),
|
||||
format!("local Fault on {hook_ref}: 0x{:02X}", message.fault.0),
|
||||
);
|
||||
if let Some(hook_id) = header.hook_id {
|
||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||
@@ -140,4 +110,45 @@ impl Simulation {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_hook_snapshot(
|
||||
&mut self,
|
||||
hook_id: u64,
|
||||
text: &str,
|
||||
payload_len: usize,
|
||||
end_hook: bool,
|
||||
) {
|
||||
if let Some(snapshot) = self.hooks.get_mut(&hook_id) {
|
||||
// Keep the most recent human-readable payload in the UI.
|
||||
snapshot.last_message = if text.is_empty() {
|
||||
format!("binary payload ({payload_len} bytes)")
|
||||
} else {
|
||||
text.to_owned()
|
||||
};
|
||||
if end_hook {
|
||||
snapshot.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_session_for_event(
|
||||
&self,
|
||||
node_id: NodeId,
|
||||
hook_id: Option<u64>,
|
||||
) -> Option<super::super::super::types::ChatSession> {
|
||||
self.chat_sessions
|
||||
.get(&hook_id.unwrap_or(0))
|
||||
.cloned()
|
||||
.filter(|session| session.node_id == node_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_reply_for_text(text: &str) -> Option<(String, bool)> {
|
||||
if text.eq_ignore_ascii_case("bye") {
|
||||
return Some(("chat session closed".to_owned(), true));
|
||||
}
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((format!("chat ack: {}", text.to_uppercase()), false))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Root-side knowledge learning from returned data.
|
||||
//!
|
||||
//! The simulator learns only from data that arrives back at the root on a known
|
||||
//! hook. This keeps the realistic inspector aligned with what the UI-triggered
|
||||
//! action actually observed.
|
||||
|
||||
use unshell::protocol::{
|
||||
DataMessage, EndpointIntrospection, LeafIntrospection, deserialize_archived_bytes,
|
||||
@@ -17,23 +21,7 @@ impl Simulation {
|
||||
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);
|
||||
}
|
||||
self.learn_from_root_introspection(&snapshot, &demo_node, message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,3 +42,33 @@ impl Simulation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
fn learn_from_root_introspection(
|
||||
&mut self,
|
||||
snapshot: &super::super::types::HookSnapshot,
|
||||
demo_node: &crate::model::DemoNode,
|
||||
message: &DataMessage,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(introspection) = deserialize_archived_bytes::<
|
||||
unshell::protocol::introspection::ArchivedEndpointIntrospection,
|
||||
EndpointIntrospection,
|
||||
>(&message.data)
|
||||
{
|
||||
self.root_knowledge
|
||||
.remember_endpoint_introspection(demo_node, &introspection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user