feat: unify object observation API

This commit is contained in:
2026-09-30 18:19:12 +09:00
parent 2e4196a1ec
commit ea7b0f00ea
32 changed files with 1035 additions and 1251 deletions
+3 -4
View File
@@ -80,7 +80,6 @@ All required actions are keyboard-accessible.
| `←`/`h` | Collapse a node or move to its parent |
| `Tab` | Cycle Tree → Selected Entry → Operations → History |
| `r` | Reobserve the selected Entry and its direct subtree |
| `+` | Double the selected node limit and retry an incomplete/truncated tree |
| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled |
| `R` | Reconnect after connection failure |
| `?` | Open help |
@@ -94,9 +93,9 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
## Screen model
- **Worldspace tree** starts at `/` and materializes direct children only when a
node is expanded. Text labels distinguish unloaded, loading, empty, stale,
failed, and truncated state; color is supplemental. A truncated node is never
presented as a complete tree.
node is expanded. Text labels distinguish unloaded, loading, empty, stale, and
failed state; color is supplemental. Every successful expansion is complete for
its requested depth.
- **Selected Entry** shows only public WIP path, object name, description, ordered
interfaces, and freshness. Validators, credentials, Host internals, and backend
paths are deliberately hidden. If current metadata disappears, old text is not
+10 -50
View File
@@ -31,8 +31,6 @@ pub enum InputKey {
Backspace,
/// Refresh the selected Entry.
Refresh,
/// Retry a truncated tree with a larger limit.
IncreaseLimit,
/// Cancel the newest cancel-safe retrieval.
CancelRead,
/// Show key help.
@@ -123,14 +121,10 @@ pub struct EntryView {
pub tree_state: ViewState,
/// Whether the user expanded this row.
pub expanded: bool,
/// Whether the observed child list is incomplete.
pub truncated: bool,
/// Current bounded expansion limit.
pub limit: u32,
}
impl EntryView {
fn placeholder(path: impl Into<String>, default_limit: u32) -> Self {
fn placeholder(path: impl Into<String>) -> Self {
Self {
path: path.into(),
object: None,
@@ -138,8 +132,6 @@ impl EntryView {
children: Vec::new(),
tree_state: ViewState::Unloaded,
expanded: false,
truncated: false,
limit: default_limit,
}
}
}
@@ -242,15 +234,11 @@ pub enum Action {
Expand {
/// Tree root path.
path: String,
/// Maximum returned nodes.
limit: u32,
},
/// Reobserve an Entry and its direct children.
Refresh {
/// Selected path.
path: String,
/// Current tree limit.
limit: u32,
},
/// Retrieve one interface descriptor.
FetchInterface(String),
@@ -267,7 +255,7 @@ pub enum Action {
},
/// Cancel the newest safe retrieval.
CancelRead,
/// Reconnect and fetch the root.
/// Reconnect and observe the root.
Reconnect,
/// Navigate to a descriptor-declared Entry result.
NavigateEntry(String),
@@ -302,8 +290,6 @@ pub struct App {
pub history: Vec<HistoryItem>,
/// Maximum retained presentation history.
pub history_limit: usize,
/// Default node limit for lazy expansion.
pub default_tree_limit: u32,
/// One-line status for local action feedback.
pub status: String,
}
@@ -312,9 +298,8 @@ impl App {
/// Creates a disconnected model with `/` selected.
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
let default_tree_limit = 64;
let mut entries = BTreeMap::new();
entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit));
entries.insert("/".into(), EntryView::placeholder("/"));
Self {
endpoint: endpoint.into(),
connection: ConnectionState::Disconnected,
@@ -328,7 +313,6 @@ impl App {
in_flight: BTreeMap::new(),
history: Vec::new(),
history_limit: 64,
default_tree_limit,
status: "press ? for help".into(),
}
}
@@ -441,14 +425,11 @@ impl App {
let node = self
.entries
.entry(self.selected_path.clone())
.or_insert_with(|| {
EntryView::placeholder(&self.selected_path, self.default_tree_limit)
});
.or_insert_with(|| EntryView::placeholder(&self.selected_path));
node.expanded = true;
if !matches!(node.tree_state, ViewState::Fresh) || node.truncated {
if !matches!(node.tree_state, ViewState::Fresh) {
return vec![Action::Expand {
path: node.path.clone(),
limit: node.limit,
}];
}
}
@@ -464,18 +445,6 @@ impl App {
}
}
InputKey::Refresh => return vec![self.refresh_action()],
InputKey::IncreaseLimit => {
let node = self
.entries
.get_mut(&self.selected_path)
.expect("selected exists");
node.limit = node.limit.saturating_mul(2).max(1);
node.expanded = true;
return vec![Action::Expand {
path: node.path.clone(),
limit: node.limit,
}];
}
_ => {}
}
self.operation_cursor = 0;
@@ -537,13 +506,8 @@ impl App {
}
fn refresh_action(&self) -> Action {
let limit = self
.entries
.get(&self.selected_path)
.map_or(self.default_tree_limit, |entry| entry.limit);
Action::Refresh {
path: self.selected_path.clone(),
limit,
}
}
@@ -591,7 +555,6 @@ impl App {
let children = observation.children.clone();
let entry = self.ensure_entry(&observation.path);
entry.children = children.clone();
entry.truncated = observation.truncated;
entry.tree_state = ViewState::from(&observation.state);
entry.expanded = true;
for path in children {
@@ -725,7 +688,7 @@ impl App {
}
self.entries
.entry(path.to_owned())
.or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit))
.or_insert_with(|| EntryView::placeholder(path))
}
fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView {
@@ -799,23 +762,20 @@ mod tests {
}
#[test]
fn tree_navigation_expansion_parent_and_limit_are_pure() {
fn tree_navigation_and_lazy_expansion_are_pure() {
let mut app = App::new("https://example.test/wip");
app.apply_tree(TreeObservation {
path: "/".into(),
children: vec!["/a".into(), "/b".into()],
truncated: true,
revision: request_id(),
state: ObservationState::Fresh,
});
assert_eq!(app.visible_paths(), ["/", "/a", "/b"]);
app.reduce(InputKey::Down);
assert_eq!(app.selected_path, "/a");
app.reduce(InputKey::Left);
assert_eq!(app.selected_path, "/");
assert!(matches!(
app.reduce(InputKey::IncreaseLimit).as_slice(),
[Action::Expand { path, limit: 128 }] if path == "/"
app.reduce(InputKey::Right).as_slice(),
[Action::Expand { path }] if path == "/a"
));
}
@@ -916,6 +876,6 @@ mod tests {
wip_client::SecurityContext::new("test"),
)
.unwrap();
client.prepare_fetch(&session, "/").unwrap().id
client.prepare_observe(&session, "/", 0).unwrap().id
}
}
+2 -21
View File
@@ -113,13 +113,8 @@ fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) {
ViewState::Fresh if node.children.is_empty() => " [empty]",
_ => "",
};
let incomplete = if node.truncated {
" [truncated: + to increase limit]"
} else {
""
};
let object = state_suffix(&node.state);
format!("{object}{tree}{incomplete}")
format!("{object}{tree}")
},
);
suffix = sanitize(&suffix);
@@ -178,14 +173,6 @@ fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) {
"No current object metadata; refresh required (old metadata hidden).",
));
}
if entry.truncated {
lines.push(Line::styled(
"TRUNCATED: this is not a complete subtree.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
}
frame.render_widget(
Paragraph::new(lines)
@@ -355,7 +342,7 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
"Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.",
)
.block(Block::default().borders(Borders::ALL).title(" Help "))
.wrap(Wrap { trim: false }),
@@ -625,7 +612,6 @@ mod tests {
fn snapshot_contains_non_color_state_markers_and_hides_validators() {
let mut app = App::new("https://example.test/wip/");
app.connection = ConnectionState::Connected;
app.entries.get_mut("/").unwrap().truncated = true;
app.entries.get_mut("/").unwrap().state = ViewState::Stale;
app.push_history(HistoryItem {
kind: HistoryKind::OutcomeUnknown,
@@ -634,7 +620,6 @@ mod tests {
entry_target: None,
});
let snapshot = rendered(&app, 110, 30);
assert!(snapshot.contains("truncated"));
assert!(snapshot.contains("stale"));
assert!(snapshot.contains("OUTCOME UNKNOWN"));
assert!(!snapshot.contains("validator"));
@@ -740,8 +725,6 @@ mod tests {
children: vec![],
tree_state: ViewState::Unloaded,
expanded: false,
truncated: false,
limit: 1,
},
);
}
@@ -832,8 +815,6 @@ mod tests {
children: vec![],
tree_state: ViewState::Unloaded,
expanded: false,
truncated: false,
limit: 1,
};
assert_eq!(entry.path, "/");
}
+22 -27
View File
@@ -304,8 +304,7 @@ impl<T: Transport> Runtime<T> {
/// Starts or restarts observation at Worldspace `/`.
pub fn connect(&mut self) {
self.app.connection = ConnectionState::Connecting;
let limit = self.app.default_tree_limit;
match self.client.prepare_tree(&self.session, "/", 1, limit) {
match self.client.prepare_observe(&self.session, "/", 1) {
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
Err(error) => self.local_error("connect", error),
}
@@ -348,20 +347,12 @@ impl<T: Transport> Runtime<T> {
fn perform(&mut self, action: Action) -> bool {
match action {
Action::Expand { path, limit } => {
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("tree expansion", error),
}
}
Action::Refresh { path, limit } => {
match self.client.prepare_fetch(&self.session, &path) {
Ok(request) => {
self.dispatch(request, InFlightKind::Object(path.clone()), false)
}
Err(error) => self.local_error("Entry refresh", error),
}
match self.client.prepare_tree(&self.session, &path, 1, limit) {
Action::Expand { path } => match self.client.prepare_observe(&self.session, &path, 1) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("tree expansion", error),
},
Action::Refresh { path } => {
match self.client.prepare_observe(&self.session, &path, 1) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error),
}
@@ -402,7 +393,7 @@ impl<T: Transport> Runtime<T> {
Action::Reconnect => self.connect(),
Action::NavigateEntry(path) => {
self.app.navigate_entry(path.clone());
match self.client.prepare_fetch(&self.session, &path) {
match self.client.prepare_observe(&self.session, &path, 0) {
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
Err(error) => self.local_error("Entry navigation", error),
}
@@ -580,13 +571,17 @@ impl<T: Transport> Runtime<T> {
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
match completion {
Completion::Object(observation) => self.app.apply_object(observation),
Completion::Tree(observation) => {
let root = observation.path.clone();
self.app.apply_tree(observation);
self.sync_all_objects();
if root == "/" {
self.app.connection = ConnectionState::Connected;
Completion::Object(observation) => {
let path = observation.path.clone();
self.app.apply_object(observation);
if matches!(kind, Some(InFlightKind::Tree(_))) {
if let Some(observation) = self.client.tree(&self.session, &path).cloned() {
self.app.apply_tree(observation);
}
self.sync_all_objects();
if path == "/" {
self.app.connection = ConnectionState::Connected;
}
}
}
Completion::Interface(observation) => self.app.apply_interface(observation),
@@ -708,7 +703,7 @@ impl<T: Transport> Runtime<T> {
fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) {
let title = match kind {
Some(InFlightKind::Object(path)) => format!("fetch {path}"),
Some(InFlightKind::Object(path)) => format!("observe {path}"),
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
Some(InFlightKind::Interface(reference)) => format!("interface {reference}"),
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
@@ -1029,7 +1024,7 @@ mod tests {
let transport = HttpTransport::new(&config, 1024).unwrap();
let off_origin = Request::builder()
.method("POST")
.uri("https://attacker.test/wip/v1/fetch")
.uri("https://attacker.test/wip/v1/observe")
.body(Vec::new())
.unwrap();
let error = transport.prepare(off_origin).unwrap_err();
@@ -1039,7 +1034,7 @@ mod tests {
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
let valid = Request::builder()
.method("POST")
.uri(endpoint.route(wip_https::Route::Fetch).as_str())
.uri(endpoint.route(wip_https::Route::Observe).as_str())
.body(Vec::new())
.unwrap();
let prepared = transport.prepare(valid).unwrap();
-1
View File
@@ -97,7 +97,6 @@ pub fn translate_key(event: KeyEvent, editing: bool) -> Option<InputKey> {
KeyCode::Char('l') => InputKey::Right,
KeyCode::Char('r') => InputKey::Refresh,
KeyCode::Char('R') => InputKey::Reconnect,
KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit,
KeyCode::Char('c') => InputKey::CancelRead,
KeyCode::Char('?') => InputKey::Help,
KeyCode::Char('q') => InputKey::Quit,
+6 -6
View File
@@ -50,7 +50,7 @@ impl Transport for ScriptedTransport {
detail: "scripted local conversion failure".into(),
});
}
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/fetch_tree") {
if self.fail_tree_prepare && request.uri().path().ends_with("/v1/observe") {
return Err(TransportError {
kind: TransportErrorKind::Disconnect,
detail: "scripted tree conversion failure".into(),
@@ -61,7 +61,7 @@ impl Transport for ScriptedTransport {
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
if request.uri().path().ends_with("/v1/fetch_tree")
if request.uri().path().ends_with("/v1/observe")
&& self
.root_tree_protocol_failures
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
@@ -70,7 +70,7 @@ impl Transport for ScriptedTransport {
.is_ok()
{
return Ok(encode_protocol_error_response(
ProtocolInteraction::FetchTree,
ProtocolInteraction::Observe,
&ProtocolError {
code: ProtocolErrorCode::Internal,
message: "scripted startup failure".into(),
@@ -80,9 +80,9 @@ impl Transport for ScriptedTransport {
.expect("encode tree failure"));
}
if self.delayed_after_startup && sequence > 0 {
// Each refresh starts object then tree. Make the first pair lose to
// the second pair to exercise exact RequestId ownership.
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 };
// Delay the first refresh so the second wins and exercises exact
// RequestId ownership.
let delay = if sequence == 1 { 80 } else { 5 };
thread::sleep(Duration::from_millis(delay));
}
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {