feat: prefetch visible interface descriptors
This commit is contained in:
@@ -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<String>,
|
||||
) -> Result<Option<PreparedRequest>, 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>, visible: &mut Vec<String>) {
|
||||
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");
|
||||
|
||||
@@ -408,7 +408,9 @@ impl<T: Transport> Runtime<T> {
|
||||
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<T: Transport> Runtime<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user