feat: prefetch visible interface descriptors

This commit is contained in:
2026-10-01 08:58:25 +09:00
parent 06edf62d4e
commit 511e9fef9f
7 changed files with 252 additions and 25 deletions
+1 -1
View File
@@ -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. |
+28 -1
View File
@@ -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,
+111
View File
@@ -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);
+11 -8
View File
@@ -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
+70
View File
@@ -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");
+15 -1
View File
@@ -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) {
+16 -14
View File
@@ -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"