feat: prefetch visible observation frontier

This commit is contained in:
2026-09-30 19:26:50 +09:00
parent ea7b0f00ea
commit 06edf62d4e
9 changed files with 440 additions and 68 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, lazy entry trees, 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 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-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. |
+128 -3
View File
@@ -1,7 +1,8 @@
//! Presentation-independent stateful runtime for WIP clients. //! Presentation-independent stateful runtime for WIP clients.
//! //!
//! [`Client`] separates observations by canonical HTTPS endpoint and caller- //! [`Client`] separates observations by canonical HTTPS endpoint and caller-
//! supplied security context. It creates requests with the standard //! supplied security context. It reconciles requested observation coverage without
//! duplicating fresh or loading work, creates requests with the standard
//! [`wip_https`] adapter, correlates responses by [`RequestId`], and retains //! [`wip_https`] adapter, correlates responses by [`RequestId`], and retains
//! bounded object, tree, interface, diagnostic, and operation state. The crate //! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types. //! contains no terminal, widget, or UI event types.
@@ -630,8 +631,64 @@ impl Client {
} }
} }
/// Starts or refreshes an object observation through the requested depth. /// Ensures that an observation is available through `depth` without issuing
pub fn prepare_observe( /// duplicate work.
///
/// Returns `None` when the requested coverage is already fresh, is currently
/// loading, is blocked by a retained failure or stale object state that requires
/// an explicit refresh, or when the bounded in-flight capacity is currently
/// full. Callers may invoke this method again after applying another completion.
pub fn ensure_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
) -> Result<Option<PreparedRequest>, ClientError> {
let path = path.into();
wip_protocol::validate_path(&path).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
let coverage = observation_coverage(self.session(session)?, &path, depth);
if coverage != ObservationCoverage::Missing || self.pending.len() >= self.limits.in_flight {
return Ok(None);
}
self.refresh_observed(session, path, depth).map(Some)
}
/// Ensures that an object's direct children have been observed without
/// duplicating fresh or loading work.
pub fn ensure_children_observed(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<Option<PreparedRequest>, ClientError> {
self.ensure_observed(session, path, 1)
}
/// Explicitly refreshes only one object while preserving previously observed
/// children in Known Space.
pub fn refresh_object(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.refresh_observed(session, path, 0)
}
/// Explicitly refreshes one object and its direct children.
pub fn refresh_children(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.refresh_observed(session, path, 1)
}
/// Explicitly starts or refreshes an object observation through `depth`.
///
/// Unlike [`Client::ensure_observed`], this always creates a new request and
/// supersedes older requests for the same root path.
pub fn refresh_observed(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
path: impl Into<String>, path: impl Into<String>,
@@ -1430,6 +1487,64 @@ enum RetrievalSubject {
Interface(String), Interface(String),
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObservationCoverage {
Missing,
Loading,
Covered,
Blocked,
}
fn observation_coverage(state: &Session, path: &str, depth: u32) -> ObservationCoverage {
let Some(entry) = state.entries.get(path) else {
return ObservationCoverage::Missing;
};
match &entry.state {
ObservationState::Loading(_) => return ObservationCoverage::Loading,
ObservationState::Stale | ObservationState::Error(_) => {
return ObservationCoverage::Blocked;
}
ObservationState::Fresh => {}
}
if entry
.object_key
.is_none_or(|key| !state.objects.contains_key(&key))
{
return ObservationCoverage::Missing;
}
if depth == 0 {
return ObservationCoverage::Covered;
}
let Some(tree) = state.trees.get(path) else {
return ObservationCoverage::Missing;
};
match &tree.state {
ObservationState::Loading(_) => return ObservationCoverage::Loading,
ObservationState::Stale => return ObservationCoverage::Missing,
ObservationState::Error(_) => return ObservationCoverage::Blocked,
ObservationState::Fresh => {}
}
let mut saw_loading = false;
let mut saw_missing = false;
for child in &tree.children {
match observation_coverage(state, child, depth - 1) {
ObservationCoverage::Blocked => return ObservationCoverage::Blocked,
ObservationCoverage::Loading => saw_loading = true,
ObservationCoverage::Missing => saw_missing = true,
ObservationCoverage::Covered => {}
}
}
if saw_loading {
ObservationCoverage::Loading
} else if saw_missing {
ObservationCoverage::Missing
} else {
ObservationCoverage::Covered
}
}
fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> { fn session_endpoint(session: &SessionId) -> Result<Endpoint, ClientError> {
Endpoint::parse(&session.endpoint) Endpoint::parse(&session.endpoint)
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())) .map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))
@@ -1883,6 +1998,16 @@ fn apply_observation(
.collect(); .collect();
reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?; reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?;
for (path, _, children) in &nodes {
if children.is_none()
&& let Some(tree) = state.trees.get_mut(path)
&& tree.revision < request_id
&& matches!(tree.state, ObservationState::Loading(_))
{
tree.state = ObservationState::Stale;
}
}
let mut omitted = Vec::new(); let mut omitted = Vec::new();
for (path, _, children) in &nodes { for (path, _, children) in &nodes {
let Some(children) = children else { let Some(children) = children else {
+5 -5
View File
@@ -77,7 +77,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
]; ];
for body in cases { for body in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_observe(&session, "/item", 0).unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = client let error = client
.complete(request.id, response(StatusCode::OK, body)) .complete(request.id, response(StatusCode::OK, body))
.unwrap_err(); .unwrap_err();
@@ -107,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
]; ];
for (status, body) in cases { for (status, body) in cases {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_observe(&session, "/item", 0).unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.complete(request.id, response(status, body)) .complete(request.id, response(status, body))
@@ -121,7 +121,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
#[test] #[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() { fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_observe(&session, "/item", 0).unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = ProtocolError { let error = ProtocolError {
code: ProtocolErrorCode::NotFound, code: ProtocolErrorCode::NotFound,
message: "missing".into(), message: "missing".into(),
@@ -147,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
#[test] #[test]
fn non_success_without_valid_envelope_remains_transport_failure() { fn non_success_without_valid_envelope_remains_transport_failure() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let request = client.prepare_observe(&session, "/item", 0).unwrap(); let request = client.refresh_observed(&session, "/item", 0).unwrap();
let error = client let error = client
.complete( .complete(
request.id, request.id,
@@ -177,7 +177,7 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
#[test] #[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() { fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup(); let (mut client, session) = setup();
let observation = client.prepare_observe(&session, "/item", 0).unwrap(); let observation = client.refresh_observed(&session, "/item", 0).unwrap();
client client
.complete( .complete(
observation.id, observation.id,
+234 -43
View File
@@ -109,7 +109,7 @@ fn observation_response(path: &str, value: &Object) -> Response<Vec<u8>> {
} }
fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) { fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.prepare_observe(session, path, 0).unwrap(); let request = client.refresh_observed(session, path, 0).unwrap();
client client
.complete(request.id, observation_response(path, &value)) .complete(request.id, observation_response(path, &value))
.unwrap(); .unwrap();
@@ -173,14 +173,129 @@ fn sessions_partition_known_space_by_canonical_endpoint_and_security_context() {
assert!(client.object(&other, "/item").is_none()); assert!(client.object(&other, "/item").is_none());
} }
#[test]
fn ensure_observed_deduplicates_loading_and_fresh_coverage() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let value = TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children: vec![TestObservation {
object: object("child", None, b"child"),
children: vec![],
}],
},
};
let root = client
.ensure_observed(&session, "/", 1)
.unwrap()
.expect("missing coverage starts an observation");
assert!(client.ensure_observed(&session, "/", 1).unwrap().is_none());
client
.complete(
root.id,
encode_test_observation_response(&request, &value, wire_limits()).unwrap(),
)
.unwrap();
assert!(client.ensure_observed(&session, "/", 1).unwrap().is_none());
let child = client
.ensure_observed(&session, "/child", 1)
.unwrap()
.expect("depth-boundary children remain missing coverage");
assert!(
client
.ensure_observed(&session, "/child", 1)
.unwrap()
.is_none()
);
client
.complete(
child.id,
encode_test_observation_response(
&ObserveRequest {
path: "/child".into(),
depth: 1,
},
&TestObservationResponse {
root: TestObservation {
object: object("child", None, b"child"),
children: vec![],
},
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
assert!(
client
.ensure_observed(&session, "/child", 1)
.unwrap()
.is_none()
);
}
#[test]
fn ensure_observed_respects_capacity_and_does_not_loop_on_failure() {
let mut client = client(8, 8, 1, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let first = client
.ensure_observed(&session, "/one", 0)
.unwrap()
.expect("first observation fits");
assert_eq!(
client
.ensure_observed(&session, "relative", 0)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert!(
client
.ensure_observed(&session, "/two", 0)
.unwrap()
.is_none()
);
assert_eq!(
client
.fail_transport(first.id, "scripted failure")
.unwrap_err()
.kind,
ClientErrorKind::Transport
);
assert!(
client
.ensure_observed(&session, "/one", 0)
.unwrap()
.is_none(),
"retained failures require an explicit refresh"
);
assert!(
client
.ensure_observed(&session, "/two", 0)
.unwrap()
.is_some()
);
}
#[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);
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap(); let old = client.refresh_observed(&session, "/item", 0).unwrap();
let new = client.prepare_observe(&session, "/item", 0).unwrap(); let new = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
@@ -227,7 +342,7 @@ fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() {
object("shared", Some("shared-ref"), b"v1"), object("shared", Some("shared-ref"), b"v1"),
); );
let conflict = client.prepare_observe(&session, "/b/shared", 0).unwrap(); let conflict = client.refresh_observed(&session, "/b/shared", 0).unwrap();
let mut changed = object("shared", Some("shared-ref"), b"v1"); let mut changed = object("shared", Some("shared-ref"), b"v1");
changed.description = Some("contradiction".into()); changed.description = Some("contradiction".into());
let error = client let error = client
@@ -279,8 +394,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
}, },
}; };
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
newer_alias.id, newer_alias.id,
@@ -303,8 +418,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newer".to_vec()) Some(b"newer".to_vec())
); );
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
older_tree.id, older_tree.id,
@@ -334,8 +449,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newest".to_vec()) Some(b"newest".to_vec())
); );
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap(); let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap(); let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client client
.complete( .complete(
newer_alias.id, newer_alias.id,
@@ -378,8 +493,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/child", "/child",
object("child", Some("child-ref"), b"stable"), object("child", Some("child-ref"), b"stable"),
); );
let older_tree = client.prepare_observe(&session, "/", 1).unwrap(); let older_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 0).unwrap(); let newer_child = client.refresh_observed(&session, "/child", 0).unwrap();
client client
.complete( .complete(
newer_child.id, newer_child.id,
@@ -426,7 +541,7 @@ fn observation_expansion_is_complete_and_refreshes_edges() {
.unwrap(); .unwrap();
assert!(client.tree(&session, "/").is_none()); assert!(client.tree(&session, "/").is_none());
let request = client.prepare_observe(&session, "/", 1).unwrap(); let request = client.refresh_observed(&session, "/", 1).unwrap();
let logical_request = ObserveRequest { let logical_request = ObserveRequest {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
@@ -449,7 +564,7 @@ fn observation_expansion_is_complete_and_refreshes_edges() {
assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]); assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]);
assert!(client.object(&session, "/items").is_some()); assert!(client.object(&session, "/items").is_some());
let refresh = client.prepare_observe(&session, "/", 1).unwrap(); let refresh = client.refresh_observed(&session, "/", 1).unwrap();
let refreshed = TestObservationResponse { let refreshed = TestObservationResponse {
root: TestObservation { root: TestObservation {
object: object("", None, b"root-2"), object: object("", None, b"root-2"),
@@ -479,7 +594,7 @@ fn depth_zero_observation_preserves_previously_observed_children() {
path: "/".into(), path: "/".into(),
depth: 1, depth: 1,
}; };
let deep = client.prepare_observe(&session, "/", 1).unwrap(); let deep = client.refresh_observed(&session, "/", 1).unwrap();
client client
.complete( .complete(
deep.id, deep.id,
@@ -500,7 +615,7 @@ fn depth_zero_observation_preserves_previously_observed_children() {
) )
.unwrap(); .unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap(); let shallow = client.refresh_observed(&session, "/", 0).unwrap();
client client
.complete( .complete(
shallow.id, shallow.id,
@@ -536,8 +651,8 @@ fn shallow_observation_supersedes_loading_deep_observation_without_leaking_loadi
}, },
}; };
let deep = client.prepare_observe(&session, "/", 1).unwrap(); let deep = client.refresh_observed(&session, "/", 1).unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap(); let shallow = client.refresh_observed(&session, "/", 0).unwrap();
assert_eq!( assert_eq!(
client.tree(&session, "/").unwrap().state, client.tree(&session, "/").unwrap().state,
ObservationState::Stale ObservationState::Stale
@@ -564,6 +679,80 @@ fn shallow_observation_supersedes_loading_deep_observation_without_leaking_loadi
); );
} }
#[test]
fn newer_parent_boundary_observation_replans_superseded_child_lookahead() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let root_request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let root_value = |validator: &'static [u8]| TestObservationResponse {
root: TestObservation {
object: object("", None, validator),
children: vec![TestObservation {
object: object("child", None, validator),
children: vec![],
}],
},
};
let initial = client.refresh_observed(&session, "/", 1).unwrap();
client
.complete(
initial.id,
encode_test_observation_response(&root_request, &root_value(b"initial"), wire_limits())
.unwrap(),
)
.unwrap();
let older_child = client.refresh_observed(&session, "/child", 1).unwrap();
let newer_parent = client.refresh_observed(&session, "/", 1).unwrap();
client
.complete(
newer_parent.id,
encode_test_observation_response(&root_request, &root_value(b"newer"), wire_limits())
.unwrap(),
)
.unwrap();
assert_eq!(
client.tree(&session, "/child").unwrap().state,
ObservationState::Stale
);
let replanned = client
.ensure_observed(&session, "/child", 1)
.unwrap()
.expect("superseded lookahead is missing rather than permanently loading");
assert_eq!(
client
.complete(
older_child.id,
encode_test_observation_response(
&ObserveRequest {
path: "/child".into(),
depth: 1,
},
&TestObservationResponse {
root: TestObservation {
object: object("child", None, b"older"),
children: vec![],
},
},
wire_limits(),
)
.unwrap(),
)
.unwrap(),
Completion::StaleResponseRejected(older_child.id)
);
assert!(matches!(
client.object(&session, "/child").unwrap().state,
ObservationState::Loading(id) if id == replanned.id
));
}
#[test] #[test]
fn call_context_fixes_membership_validators_descriptor_and_result_validation() { fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
let mut client = client(8, 8, 8, 8); let mut client = client(8, 8, 8, 8);
@@ -818,7 +1007,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
}, },
); );
let older_alias = client.prepare_observe(&session, "/alias/item", 0).unwrap(); let older_alias = client.refresh_observed(&session, "/alias/item", 0).unwrap();
let call = client let call = client
.prepare_call(&session, "/item", "math", "calculate", args(7)) .prepare_call(&session, "/item", "math", "calculate", args(7))
.unwrap(); .unwrap();
@@ -878,7 +1067,9 @@ fn older_retrieval_cannot_overwrite_success_validator() {
"/item", "/item",
object("item", Some("shared-ref"), b"object-v2"), object("item", Some("shared-ref"), b"object-v2"),
); );
let older_alias = client.prepare_observe(&session, "/second/item", 0).unwrap(); let older_alias = client
.refresh_observed(&session, "/second/item", 0)
.unwrap();
let call = client let call = client
.prepare_call(&session, "/item", "math", "calculate", args(8)) .prepare_call(&session, "/item", "math", "calculate", args(8))
.unwrap(); .unwrap();
@@ -941,7 +1132,7 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
assert_eq!( assert_eq!(
client client
.prepare_observe(&session, "/other", 0) .refresh_observed(&session, "/other", 0)
.unwrap_err() .unwrap_err()
.kind, .kind,
ClientErrorKind::Capacity ClientErrorKind::Capacity
@@ -1129,8 +1320,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
}, },
}; };
let old_tree = client.prepare_observe(&session, "/", 1).unwrap(); let old_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); let newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client client
.complete( .complete(
old_tree.id, old_tree.id,
@@ -1152,8 +1343,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"newer-observation".to_vec()) Some(b"newer-observation".to_vec())
); );
let older_tree = client.prepare_observe(&session, "/", 1).unwrap(); let older_tree = client.refresh_observed(&session, "/", 1).unwrap();
let completed_newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); let completed_newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client client
.complete( .complete(
completed_newer_observation.id, completed_newer_observation.id,
@@ -1171,8 +1362,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"completed-newer".to_vec()) Some(b"completed-newer".to_vec())
); );
let older_omitting_tree = client.prepare_observe(&session, "/", 1).unwrap(); let older_omitting_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap(); let newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client client
.complete( .complete(
newer_observation.id, newer_observation.id,
@@ -1199,8 +1390,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
assert_eq!(child.state, ObservationState::Fresh); assert_eq!(child.state, ObservationState::Fresh);
assert_eq!(child.validator, Some(b"newer-than-omission".to_vec())); assert_eq!(child.validator, Some(b"newer-than-omission".to_vec()));
let old_tree = client.prepare_observe(&session, "/", 1).unwrap(); let old_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child_tree = client.prepare_observe(&session, "/child", 1).unwrap(); let newer_child_tree = client.refresh_observed(&session, "/child", 1).unwrap();
client client
.complete( .complete(
old_tree.id, old_tree.id,
@@ -1241,8 +1432,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
ObservationState::Fresh ObservationState::Fresh
); );
let older_parent = client.prepare_observe(&session, "/", 1).unwrap(); let older_parent = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 1).unwrap(); let newer_child = client.refresh_observed(&session, "/child", 1).unwrap();
client client
.complete( .complete(
newer_child.id, newer_child.id,
@@ -1281,8 +1472,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap(); let old = client.refresh_observed(&session, "/item", 0).unwrap();
let newer = client.prepare_observe(&session, "/item", 0).unwrap(); let newer = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(old.id, "old connection failed") .fail_transport(old.id, "old connection failed")
@@ -1300,8 +1491,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
) )
.unwrap(); .unwrap();
let old = client.prepare_observe(&session, "/", 1).unwrap(); let old = client.refresh_observed(&session, "/", 1).unwrap();
let newer_tree = client.prepare_observe(&session, "/", 1).unwrap(); let newer_tree = client.refresh_observed(&session, "/", 1).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(old.id, "old tree connection failed") .fail_transport(old.id, "old tree connection failed")
@@ -1410,10 +1601,10 @@ fn entry_eviction_never_discards_a_loading_tree_subject() {
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
observe_object(&mut client, &session, "/", object("", None, b"root")); observe_object(&mut client, &session, "/", object("", None, b"root"));
let tree = client.prepare_observe(&session, "/", 1).unwrap(); let tree = client.refresh_observed(&session, "/", 1).unwrap();
assert_eq!( assert_eq!(
client client
.prepare_observe(&session, "/other", 0) .refresh_observed(&session, "/other", 0)
.unwrap_err() .unwrap_err()
.kind, .kind,
ClientErrorKind::Capacity ClientErrorKind::Capacity
@@ -1454,7 +1645,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
for path in ["/one", "/two", "/three"] { for path in ["/one", "/two", "/three"] {
let request = client.prepare_observe(&session, path, 0).unwrap(); let request = client.refresh_observed(&session, path, 0).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(request.id, "retrieval failed") .fail_transport(request.id, "retrieval failed")
@@ -1468,7 +1659,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
assert!(client.object(&session, "/three").is_some()); assert!(client.object(&session, "/three").is_some());
for path in ["/alpha", "/beta", "/gamma"] { for path in ["/alpha", "/beta", "/gamma"] {
let request = client.prepare_observe(&session, path, 1).unwrap(); let request = client.refresh_observed(&session, path, 1).unwrap();
assert_eq!( assert_eq!(
client client
.fail_transport(request.id, "tree failed") .fail_transport(request.id, "tree failed")
@@ -1487,8 +1678,8 @@ fn in_flight_and_history_are_bounded() {
let session = client let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject")) .open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap(); .unwrap();
let first = client.prepare_observe(&session, "/one", 0).unwrap(); let first = client.refresh_observed(&session, "/one", 0).unwrap();
let error = client.prepare_observe(&session, "/two", 0).unwrap_err(); let error = client.refresh_observed(&session, "/two", 0).unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Capacity); assert_eq!(error.kind, ClientErrorKind::Capacity);
client client
.complete( .complete(
@@ -1498,8 +1689,8 @@ fn in_flight_and_history_are_bounded() {
.unwrap(); .unwrap();
// Diagnostic history evicts oldest entries at the configured bound. // Diagnostic history evicts oldest entries at the configured bound.
let stale_old = client.prepare_observe(&session, "/one", 0).unwrap(); let stale_old = client.refresh_observed(&session, "/one", 0).unwrap();
let stale_new = client.prepare_observe(&session, "/one", 0).unwrap_err(); let stale_new = client.refresh_observed(&session, "/one", 0).unwrap_err();
assert_eq!(stale_new.kind, ClientErrorKind::Capacity); assert_eq!(stale_new.kind, ClientErrorKind::Capacity);
client client
.complete( .complete(
+5 -4
View File
@@ -92,10 +92,11 @@ a second explicit Enter (or `e`) to execute once. There is no operation auto-ret
## Screen model ## Screen model
- **Worldspace tree** starts at `/` and materializes direct children only when a - **Worldspace tree** starts at `/`, lazily materializes expanded nodes, and
node is expanded. Text labels distinguish unloaded, loading, empty, stale, and prefetches children for each visible Object so emptiness and the next expansion
failed state; color is supplemental. Every successful expansion is complete for frontier are known without selecting that Object first. Text labels distinguish
its requested depth. 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 - **Selected Entry** shows only public WIP path, object name, description, ordered
interfaces, and freshness. Validators, credentials, Host internals, and backend interfaces, and freshness. Validators, credentials, Host internals, and backend
paths are deliberately hidden. If current metadata disappears, old text is not paths are deliberately hidden. If current metadata disappears, old text is not
+4 -3
View File
@@ -299,7 +299,9 @@ impl App {
#[must_use] #[must_use]
pub fn new(endpoint: impl Into<String>) -> Self { pub fn new(endpoint: impl Into<String>) -> Self {
let mut entries = BTreeMap::new(); let mut entries = BTreeMap::new();
entries.insert("/".into(), EntryView::placeholder("/")); let mut root = EntryView::placeholder("/");
root.expanded = true;
entries.insert("/".into(), root);
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
connection: ConnectionState::Disconnected, connection: ConnectionState::Disconnected,
@@ -556,7 +558,6 @@ impl App {
let entry = self.ensure_entry(&observation.path); let entry = self.ensure_entry(&observation.path);
entry.children = children.clone(); entry.children = children.clone();
entry.tree_state = ViewState::from(&observation.state); entry.tree_state = ViewState::from(&observation.state);
entry.expanded = true;
for path in children { for path in children {
self.ensure_entry(&path); self.ensure_entry(&path);
} }
@@ -876,6 +877,6 @@ mod tests {
wip_client::SecurityContext::new("test"), wip_client::SecurityContext::new("test"),
) )
.unwrap(); .unwrap();
client.prepare_observe(&session, "/", 0).unwrap().id client.refresh_object(&session, "/").unwrap().id
} }
} }
+30
View File
@@ -608,6 +608,36 @@ mod tests {
.join("\n") .join("\n")
} }
#[test]
fn observed_empty_visible_entry_is_labeled_without_expansion() {
let mut app = App::new("https://example.test/wip/");
{
let root = app.entries.get_mut("/").unwrap();
root.children = vec!["/empty".into()];
root.tree_state = ViewState::Fresh;
}
app.entries.insert(
"/empty".into(),
EntryView {
path: "/empty".into(),
object: Some(Object {
name: "empty".into(),
description: None,
interfaces: vec![],
r#ref: None,
validator: None,
}),
state: ViewState::Fresh,
children: vec![],
tree_state: ViewState::Fresh,
expanded: false,
},
);
let snapshot = rendered(&app, 80, 20);
assert!(snapshot.contains("empty [empty]"));
}
#[test] #[test]
fn snapshot_contains_non_color_state_markers_and_hides_validators() { fn snapshot_contains_non_color_state_markers_and_hides_validators() {
let mut app = App::new("https://example.test/wip/"); let mut app = App::new("https://example.test/wip/");
+27 -7
View File
@@ -304,7 +304,7 @@ impl<T: Transport> Runtime<T> {
/// Starts or restarts observation at Worldspace `/`. /// Starts or restarts observation at Worldspace `/`.
pub fn connect(&mut self) { pub fn connect(&mut self) {
self.app.connection = ConnectionState::Connecting; self.app.connection = ConnectionState::Connecting;
match self.client.prepare_observe(&self.session, "/", 1) { match self.client.refresh_children(&self.session, "/") {
Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false), Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false),
Err(error) => self.local_error("connect", error), Err(error) => self.local_error("connect", error),
} }
@@ -317,6 +317,9 @@ impl<T: Transport> Runtime<T> {
for action in actions { for action in actions {
quit |= self.perform(action); quit |= self.perform(action);
} }
if !quit {
self.reconcile_visible_lookahead();
}
quit quit
} }
@@ -347,16 +350,14 @@ impl<T: Transport> Runtime<T> {
fn perform(&mut self, action: Action) -> bool { fn perform(&mut self, action: Action) -> bool {
match action { match action {
Action::Expand { path } => match self.client.prepare_observe(&self.session, &path, 1) { Action::Expand { path } => match self.client.refresh_children(&self.session, &path) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("tree expansion", error), Err(error) => self.local_error("tree expansion", error),
}, },
Action::Refresh { path } => { Action::Refresh { path } => match self.client.refresh_children(&self.session, &path) {
match self.client.prepare_observe(&self.session, &path, 1) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error), Err(error) => self.local_error("subtree refresh", error),
} },
}
Action::FetchInterface(reference) => { Action::FetchInterface(reference) => {
match self.client.prepare_interface(&self.session, &reference) { match self.client.prepare_interface(&self.session, &reference) {
Ok(request) => { Ok(request) => {
@@ -393,7 +394,7 @@ impl<T: Transport> Runtime<T> {
Action::Reconnect => self.connect(), Action::Reconnect => self.connect(),
Action::NavigateEntry(path) => { Action::NavigateEntry(path) => {
self.app.navigate_entry(path.clone()); self.app.navigate_entry(path.clone());
match self.client.prepare_observe(&self.session, &path, 0) { match self.client.refresh_object(&self.session, &path) {
Ok(request) => self.dispatch(request, InFlightKind::Object(path), false), Ok(request) => self.dispatch(request, InFlightKind::Object(path), false),
Err(error) => self.local_error("Entry navigation", error), Err(error) => self.local_error("Entry navigation", error),
} }
@@ -403,6 +404,24 @@ impl<T: Transport> Runtime<T> {
false false
} }
fn reconcile_visible_lookahead(&mut self) {
if self.app.connection != ConnectionState::Connected {
return;
}
for path in self.app.visible_paths() {
match self.client.ensure_children_observed(&self.session, &path) {
Ok(Some(request)) => {
self.dispatch(request, InFlightKind::Tree(path), false);
}
Ok(None) => {}
Err(error) => {
self.local_error("observation lookahead", error);
break;
}
}
}
}
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) { fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
let id = request.id; let id = request.id;
self.app.request_started(id.get(), kind.clone()); self.app.request_started(id.get(), kind.clone());
@@ -567,6 +586,7 @@ impl<T: Transport> Runtime<T> {
} }
} }
self.reconcile_authoritative(); self.reconcile_authoritative();
self.reconcile_visible_lookahead();
} }
fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) { fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) {
+4
View File
@@ -53,6 +53,10 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
assert!(runtime.app().entries.contains_key("/hello.txt")); assert!(runtime.app().entries.contains_key("/hello.txt"));
assert!(runtime.app().entries.contains_key("/nested")); assert!(runtime.app().entries.contains_key("/nested"));
assert!(runtime.app().entries.contains_key("/outside-link")); assert!(runtime.app().entries.contains_key("/outside-link"));
let empty_directory = &runtime.app().entries["/nested"];
assert_eq!(empty_directory.tree_state, ViewState::Fresh);
assert!(empty_directory.children.is_empty());
assert!(!empty_directory.expanded);
// Keyboard-select the file and load its read interface and descriptor. // Keyboard-select the file and load its read interface and descriptor.
runtime.app_mut().selected_path = "/hello.txt".into(); runtime.app_mut().selected_path = "/hello.txt".into();