feat: prefetch visible interface descriptors
This commit is contained in:
@@ -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-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-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-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` | 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. |
|
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
||||||
|
|||||||
@@ -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(
|
pub fn prepare_interface(
|
||||||
&mut self,
|
&mut self,
|
||||||
session: &SessionId,
|
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]
|
#[test]
|
||||||
fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
|
fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
|
||||||
let mut client = client(8, 8, 8, 8);
|
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
|
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
|
Expand `/`, select `notes.txt`, focus the Interface/Operation pane, and choose
|
||||||
interface, and choose `read`. The editor labels `offset` and `length` as optional;
|
`read`; descriptors advertised by visible Objects are normally prefetched already.
|
||||||
leave both blank to read the bounded file. `Bytes` results are shown as a bounded
|
The editor labels `offset` and `length` as optional; leave both blank to read the
|
||||||
hex preview. For `replace`, enter `contents` as RFC 4648 base64—for example,
|
bounded file. `Bytes` results are shown as a bounded hex preview. For `replace`,
|
||||||
`dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference,
|
enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
|
||||||
operation, and value on the confirmation screen before pressing Enter again.
|
`updated`—then review the target Entry, interface reference, operation, and value
|
||||||
|
on the confirmation screen before pressing Enter again.
|
||||||
|
|
||||||
## Keyboard bindings
|
## 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
|
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||||
presented as current.
|
presented as current.
|
||||||
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
- **Interfaces / Operations** identifies every operation by `(interface reference,
|
||||||
operation name)`. It shows descriptor documentation, ordered parameters,
|
operation name)`. Descriptor lookahead follows fresh visible Objects, shares the
|
||||||
required/optional status, and result types.
|
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,
|
- **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes,
|
||||||
Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record,
|
Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record,
|
||||||
List, Union, and Json values use JSON syntax. Union input is
|
List, Union, and Json values use JSON syntax. Union input is
|
||||||
|
|||||||
@@ -612,6 +612,30 @@ impl App {
|
|||||||
visible
|
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>) {
|
fn collect_visible(&self, path: &str, seen: &mut BTreeSet<String>, visible: &mut Vec<String>) {
|
||||||
if !seen.insert(path.to_owned()) {
|
if !seen.insert(path.to_owned()) {
|
||||||
return;
|
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]
|
#[test]
|
||||||
fn focus_modal_and_explicit_confirmation_gate_execution() {
|
fn focus_modal_and_explicit_confirmation_gate_execution() {
|
||||||
let mut app = App::new("https://example.test/wip");
|
let mut app = App::new("https://example.test/wip");
|
||||||
|
|||||||
@@ -408,7 +408,9 @@ impl<T: Transport> Runtime<T> {
|
|||||||
if self.app.connection != ConnectionState::Connected {
|
if self.app.connection != ConnectionState::Connected {
|
||||||
return;
|
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) {
|
match self.client.ensure_children_observed(&self.session, &path) {
|
||||||
Ok(Some(request)) => {
|
Ok(Some(request)) => {
|
||||||
self.dispatch(request, InFlightKind::Tree(path), false);
|
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) {
|
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.children.is_empty());
|
||||||
assert!(!empty_directory.expanded);
|
assert!(!empty_directory.expanded);
|
||||||
|
|
||||||
// Keyboard-select the file and load its read interface and descriptor.
|
// Visible Objects have their interface descriptors prefetched before selection.
|
||||||
runtime.app_mut().selected_path = "/hello.txt".into();
|
let file_interfaces = runtime.app().entries["/hello.txt"]
|
||||||
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"]
|
|
||||||
.object
|
.object
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("file object")
|
.expect("file object")
|
||||||
.interfaces[0]
|
.interfaces
|
||||||
.clone();
|
.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!(
|
assert_eq!(
|
||||||
runtime.app().interfaces[&read_reference]
|
runtime.app().interfaces[&read_reference]
|
||||||
.descriptor
|
.descriptor
|
||||||
@@ -80,9 +81,13 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
|||||||
"read"
|
"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.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(_)));
|
assert!(matches!(runtime.app().modal, Modal::Editor(_)));
|
||||||
runtime.handle_key(InputKey::Enter);
|
runtime.handle_key(InputKey::Enter);
|
||||||
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
|
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);
|
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
|
||||||
runtime.app_mut().focus = Focus::Operations;
|
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.
|
// 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.app_mut().operation_cursor = 3;
|
||||||
runtime.handle_key(InputKey::Enter);
|
runtime.handle_key(InputKey::Enter);
|
||||||
set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"
|
set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"
|
||||||
|
|||||||
Reference in New Issue
Block a user