From 511e9fef9fe26d4648f4499806cb6fbf7347010d Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 1 Oct 2026 08:58:25 +0900 Subject: [PATCH] feat: prefetch visible interface descriptors --- README.md | 2 +- crates/wip-client/src/lib.rs | 29 ++++++- crates/wip-client/tests/runtime.rs | 111 +++++++++++++++++++++++++ crates/wip-tui/README.md | 19 +++-- crates/wip-tui/src/app.rs | 70 ++++++++++++++++ crates/wip-tui/src/runtime.rs | 16 +++- crates/wip-tui/tests/filesystem_e2e.rs | 30 +++---- 7 files changed, 252 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6dc3149..d3585d8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ wip-public-api-example (workspace-only executable, integration tests, doctest) | --- | --- | --- | | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | | `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | -| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven observation coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | +| `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, intent-driven object and descriptor coverage with bounded request deduplication, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | | `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTPS I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index 8bd0c2b..ca2dfc6 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -751,7 +751,34 @@ impl Client { }) } - /// Starts or refreshes an interface descriptor observation. + /// Ensures that an interface descriptor has been observed without issuing + /// duplicate work or evicting another cached descriptor. + /// + /// Returns `None` when the descriptor is already fresh, is currently loading, + /// is blocked by retained stale or failure state that requires an explicit + /// refresh, or when either the interface cache or bounded in-flight capacity is + /// currently full. Callers may invoke this method again after applying another + /// completion. + pub fn ensure_interface( + &mut self, + session: &SessionId, + reference: impl Into, + ) -> Result, ClientError> { + let reference = reference.into(); + let state = self.session(session)?; + if state.interfaces.contains_key(&reference) + || state.interfaces.len() >= self.limits.interfaces_per_session + || self.pending.len() >= self.limits.in_flight + { + return Ok(None); + } + self.prepare_interface(session, reference).map(Some) + } + + /// Explicitly starts or refreshes an interface descriptor observation. + /// + /// Unlike [`Client::ensure_interface`], this always creates a new request and + /// may evict an older non-loading descriptor when the interface cache is full. pub fn prepare_interface( &mut self, session: &SessionId, diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index 7da11c2..ac16beb 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -288,6 +288,117 @@ fn ensure_observed_respects_capacity_and_does_not_loop_on_failure() { ); } +#[test] +fn ensure_interface_deduplicates_loading_fresh_and_retained_failure() { + let mut client = client(8, 8, 8, 8); + let session = client + .open_session("https://example.test/wip", SecurityContext::new("subject")) + .unwrap(); + + let request = client + .ensure_interface(&session, "math") + .unwrap() + .expect("missing descriptor starts a fetch"); + assert!(client.ensure_interface(&session, "math").unwrap().is_none()); + let value = FetchInterfaceResponse { + interface: "math".into(), + descriptor: descriptor(TypeExpr::Integer), + validator: Some(b"math-v1".to_vec()), + }; + client + .complete( + request.id, + encode_fetch_interface_response( + &FetchInterfaceRequest { + interface: "math".into(), + }, + &value, + wire_limits(), + ) + .unwrap(), + ) + .unwrap(); + assert!(client.ensure_interface(&session, "math").unwrap().is_none()); + + let failed = client + .ensure_interface(&session, "broken") + .unwrap() + .expect("new descriptor starts a fetch"); + assert_eq!( + client + .fail_transport(failed.id, "scripted failure") + .unwrap_err() + .kind, + ClientErrorKind::Transport + ); + assert!( + client + .ensure_interface(&session, "broken") + .unwrap() + .is_none(), + "retained interface failures require an explicit refresh" + ); +} + +#[test] +fn ensure_interface_waits_for_request_and_cache_capacity_without_evicting() { + let mut client = client(8, 1, 1, 8); + let session = client + .open_session("https://example.test/wip", SecurityContext::new("subject")) + .unwrap(); + let pending_object = client.refresh_object(&session, "/busy").unwrap(); + assert!( + client.ensure_interface(&session, "math").unwrap().is_none(), + "lookahead waits while the in-flight bound is full" + ); + client + .complete( + pending_object.id, + observation_response("/busy", &object("busy", None, b"busy")), + ) + .unwrap(); + + let request = client + .ensure_interface(&session, "math") + .unwrap() + .expect("lookahead resumes after capacity becomes available"); + let value = FetchInterfaceResponse { + interface: "math".into(), + descriptor: descriptor(TypeExpr::Integer), + validator: None, + }; + client + .complete( + request.id, + encode_fetch_interface_response( + &FetchInterfaceRequest { + interface: "math".into(), + }, + &value, + wire_limits(), + ) + .unwrap(), + ) + .unwrap(); + + assert!( + client + .ensure_interface(&session, "other") + .unwrap() + .is_none(), + "lookahead does not churn a full descriptor cache" + ); + assert!(client.interface(&session, "math").is_some()); + assert!(client.interface(&session, "other").is_none()); + + let explicit = client.prepare_interface(&session, "other").unwrap(); + assert!(client.interface(&session, "math").is_none()); + assert!(matches!( + client.interface(&session, "other").unwrap().state, + ObservationState::Loading(id) if id == explicit.id + )); +} + #[test] fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() { let mut client = client(8, 8, 8, 8); diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index e21832c..ef085eb 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -62,12 +62,13 @@ In another: cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http ``` -Expand `/`, select `notes.txt`, focus the Interface/Operation pane, load the read -interface, and choose `read`. The editor labels `offset` and `length` as optional; -leave both blank to read the bounded file. `Bytes` results are shown as a bounded -hex preview. For `replace`, enter `contents` as RFC 4648 base64—for example, -`dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference, -operation, and value on the confirmation screen before pressing Enter again. +Expand `/`, select `notes.txt`, focus the Interface/Operation pane, and choose +`read`; descriptors advertised by visible Objects are normally prefetched already. +The editor labels `offset` and `length` as optional; leave both blank to read the +bounded file. `Bytes` results are shown as a bounded hex preview. For `replace`, +enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for +`updated`—then review the target Entry, interface reference, operation, and value +on the confirmation screen before pressing Enter again. ## Keyboard bindings @@ -102,8 +103,10 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret paths are deliberately hidden. If current metadata disappears, old text is not presented as current. - **Interfaces / Operations** identifies every operation by `(interface reference, - operation name)`. It shows descriptor documentation, ordered parameters, - required/optional status, and result types. + operation name)`. Descriptor lookahead follows fresh visible Objects, shares the + client's in-flight/cache bounds, and never retries retained failures implicitly. + The pane shows descriptor documentation, ordered parameters, required/optional + status, and result types. - **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes, Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record, List, Union, and Json values use JSON syntax. Union input is diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs index ee8b1e2..e0259a6 100644 --- a/crates/wip-tui/src/app.rs +++ b/crates/wip-tui/src/app.rs @@ -612,6 +612,30 @@ impl App { visible } + /// Returns interface references advertised by fresh visible Objects in display + /// and per-Object declaration order, with duplicates removed. + #[must_use] + pub fn visible_interface_references(&self) -> Vec { + let mut references = Vec::new(); + let mut seen = BTreeSet::new(); + for path in self.visible_paths() { + let Some(object) = self + .entries + .get(&path) + .filter(|entry| entry.state == ViewState::Fresh) + .and_then(|entry| entry.object.as_ref()) + else { + continue; + }; + for reference in &object.interfaces { + if seen.insert(reference.clone()) { + references.push(reference.clone()); + } + } + } + references + } + fn collect_visible(&self, path: &str, seen: &mut BTreeSet, visible: &mut Vec) { if !seen.insert(path.to_owned()) { return; @@ -780,6 +804,52 @@ mod tests { )); } + #[test] + fn visible_interface_references_are_ordered_deduplicated_and_fresh() { + let mut app = App::new("https://example.test/wip"); + app.apply_tree(TreeObservation { + path: "/".into(), + children: vec!["/a".into(), "/stale".into()], + revision: request_id(), + state: ObservationState::Fresh, + }); + app.apply_object(ObjectObservation { + path: "/".into(), + object: Some(object("", &["root", "shared"])), + validator: None, + state: ObservationState::Fresh, + }); + app.apply_object(ObjectObservation { + path: "/a".into(), + object: Some(object("a", &["shared", "child"])), + validator: None, + state: ObservationState::Fresh, + }); + app.apply_object(ObjectObservation { + path: "/stale".into(), + object: Some(object("stale", &["ignored"])), + validator: None, + state: ObservationState::Stale, + }); + app.apply_tree(TreeObservation { + path: "/a".into(), + children: vec!["/a/hidden".into()], + revision: request_id(), + state: ObservationState::Fresh, + }); + app.apply_object(ObjectObservation { + path: "/a/hidden".into(), + object: Some(object("hidden", &["hidden"])), + validator: None, + state: ObservationState::Fresh, + }); + + assert_eq!( + app.visible_interface_references(), + ["root", "shared", "child"] + ); + } + #[test] fn focus_modal_and_explicit_confirmation_gate_execution() { let mut app = App::new("https://example.test/wip"); diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs index c2fd3f6..ac1cbc7 100644 --- a/crates/wip-tui/src/runtime.rs +++ b/crates/wip-tui/src/runtime.rs @@ -408,7 +408,9 @@ impl Runtime { if self.app.connection != ConnectionState::Connected { return; } - for path in self.app.visible_paths() { + let paths = self.app.visible_paths(); + let references = self.app.visible_interface_references(); + for path in paths { match self.client.ensure_children_observed(&self.session, &path) { Ok(Some(request)) => { self.dispatch(request, InFlightKind::Tree(path), false); @@ -420,6 +422,18 @@ impl Runtime { } } } + for reference in references { + match self.client.ensure_interface(&self.session, &reference) { + Ok(Some(request)) => { + self.dispatch(request, InFlightKind::Interface(reference), false); + } + Ok(None) => {} + Err(error) => { + self.local_error("interface lookahead", error); + break; + } + } + } } fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) { diff --git a/crates/wip-tui/tests/filesystem_e2e.rs b/crates/wip-tui/tests/filesystem_e2e.rs index e45df63..8773dab 100644 --- a/crates/wip-tui/tests/filesystem_e2e.rs +++ b/crates/wip-tui/tests/filesystem_e2e.rs @@ -58,18 +58,19 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { assert!(empty_directory.children.is_empty()); assert!(!empty_directory.expanded); - // Keyboard-select the file and load its read interface and descriptor. - runtime.app_mut().selected_path = "/hello.txt".into(); - runtime.app_mut().focus = Focus::Operations; - runtime.app_mut().operation_cursor = 0; - assert!(!runtime.handle_key(InputKey::Enter)); - settle(&mut runtime); - let read_reference = runtime.app().entries["/hello.txt"] + // Visible Objects have their interface descriptors prefetched before selection. + let file_interfaces = runtime.app().entries["/hello.txt"] .object .as_ref() .expect("file object") - .interfaces[0] + .interfaces .clone(); + assert!(!file_interfaces.is_empty()); + for reference in &file_interfaces { + assert_eq!(runtime.app().interfaces[reference].state, ViewState::Fresh); + assert!(runtime.app().interfaces[reference].descriptor.is_some()); + } + let read_reference = file_interfaces[0].clone(); assert_eq!( runtime.app().interfaces[&read_reference] .descriptor @@ -80,9 +81,13 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { "read" ); - // Descriptor-driven read editor, explicit confirmation, and execution. + // Keyboard-select the already prefetched read operation. + runtime.app_mut().selected_path = "/hello.txt".into(); + runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 1; - runtime.handle_key(InputKey::Enter); + assert!(!runtime.handle_key(InputKey::Enter)); + + // Descriptor-driven read editor, explicit confirmation, and execution. assert!(matches!(runtime.app().modal, Modal::Editor(_))); runtime.handle_key(InputKey::Enter); assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); @@ -98,11 +103,8 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); runtime.app_mut().focus = Focus::Operations; - // Load the separately-qualified write interface and replace with the + // Use the separately-qualified prefetched write interface and replace with the // automatically attached observed validator. - runtime.app_mut().operation_cursor = 2; - runtime.handle_key(InputKey::Enter); - settle(&mut runtime); runtime.app_mut().operation_cursor = 3; runtime.handle_key(InputKey::Enter); set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"