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-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-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. |
+128 -3
View File
@@ -1,7 +1,8 @@
//! Presentation-independent stateful runtime for WIP clients.
//!
//! [`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
//! bounded object, tree, interface, diagnostic, and operation state. The crate
//! contains no terminal, widget, or UI event types.
@@ -630,8 +631,64 @@ impl Client {
}
}
/// Starts or refreshes an object observation through the requested depth.
pub fn prepare_observe(
/// Ensures that an observation is available through `depth` without issuing
/// 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,
session: &SessionId,
path: impl Into<String>,
@@ -1430,6 +1487,64 @@ enum RetrievalSubject {
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> {
Endpoint::parse(&session.endpoint)
.map_err(|error| ClientError::new(ClientErrorKind::InvalidRequest, error.to_string()))
@@ -1883,6 +1998,16 @@ fn apply_observation(
.collect();
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();
for (path, _, children) in &nodes {
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 {
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
.complete(request.id, response(StatusCode::OK, body))
.unwrap_err();
@@ -107,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
];
for (status, body) in cases {
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!(
client
.complete(request.id, response(status, body))
@@ -121,7 +121,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
#[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
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 {
code: ProtocolErrorCode::NotFound,
message: "missing".into(),
@@ -147,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
#[test]
fn non_success_without_valid_envelope_remains_transport_failure() {
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
.complete(
request.id,
@@ -177,7 +177,7 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
#[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup();
let observation = client.prepare_observe(&session, "/item", 0).unwrap();
let observation = client.refresh_observed(&session, "/item", 0).unwrap();
client
.complete(
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) {
let request = client.prepare_observe(session, path, 0).unwrap();
let request = client.refresh_observed(session, path, 0).unwrap();
client
.complete(request.id, observation_response(path, &value))
.unwrap();
@@ -173,14 +173,129 @@ fn sessions_partition_known_space_by_canonical_endpoint_and_security_context() {
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]
fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
let mut client = client(8, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap();
let new = client.prepare_observe(&session, "/item", 0).unwrap();
let old = client.refresh_observed(&session, "/item", 0).unwrap();
let new = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!(
client
@@ -227,7 +342,7 @@ fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() {
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");
changed.description = Some("contradiction".into());
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 newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client
.complete(
newer_alias.id,
@@ -303,8 +418,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newer".to_vec())
);
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client
.complete(
older_tree.id,
@@ -334,8 +449,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newest".to_vec())
);
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
let older_tree = client.refresh_observed(&session, "/a", 1).unwrap();
let newer_alias = client.refresh_observed(&session, "/b/shared", 0).unwrap();
client
.complete(
newer_alias.id,
@@ -378,8 +493,8 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/child",
object("child", Some("child-ref"), b"stable"),
);
let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 0).unwrap();
let older_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child = client.refresh_observed(&session, "/child", 0).unwrap();
client
.complete(
newer_child.id,
@@ -426,7 +541,7 @@ fn observation_expansion_is_complete_and_refreshes_edges() {
.unwrap();
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 {
path: "/".into(),
depth: 1,
@@ -449,7 +564,7 @@ fn observation_expansion_is_complete_and_refreshes_edges() {
assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]);
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 {
root: TestObservation {
object: object("", None, b"root-2"),
@@ -479,7 +594,7 @@ fn depth_zero_observation_preserves_previously_observed_children() {
path: "/".into(),
depth: 1,
};
let deep = client.prepare_observe(&session, "/", 1).unwrap();
let deep = client.refresh_observed(&session, "/", 1).unwrap();
client
.complete(
deep.id,
@@ -500,7 +615,7 @@ fn depth_zero_observation_preserves_previously_observed_children() {
)
.unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap();
let shallow = client.refresh_observed(&session, "/", 0).unwrap();
client
.complete(
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 shallow = client.prepare_observe(&session, "/", 0).unwrap();
let deep = client.refresh_observed(&session, "/", 1).unwrap();
let shallow = client.refresh_observed(&session, "/", 0).unwrap();
assert_eq!(
client.tree(&session, "/").unwrap().state,
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]
fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
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
.prepare_call(&session, "/item", "math", "calculate", args(7))
.unwrap();
@@ -878,7 +1067,9 @@ fn older_retrieval_cannot_overwrite_success_validator() {
"/item",
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
.prepare_call(&session, "/item", "math", "calculate", args(8))
.unwrap();
@@ -941,7 +1132,7 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
assert_eq!(
client
.prepare_observe(&session, "/other", 0)
.refresh_observed(&session, "/other", 0)
.unwrap_err()
.kind,
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 newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
let old_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client
.complete(
old_tree.id,
@@ -1152,8 +1343,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"newer-observation".to_vec())
);
let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let completed_newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
let older_tree = client.refresh_observed(&session, "/", 1).unwrap();
let completed_newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client
.complete(
completed_newer_observation.id,
@@ -1171,8 +1362,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"completed-newer".to_vec())
);
let older_omitting_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
let older_omitting_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_observation = client.refresh_observed(&session, "/child", 0).unwrap();
client
.complete(
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.validator, Some(b"newer-than-omission".to_vec()));
let old_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child_tree = client.prepare_observe(&session, "/child", 1).unwrap();
let old_tree = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child_tree = client.refresh_observed(&session, "/child", 1).unwrap();
client
.complete(
old_tree.id,
@@ -1241,8 +1432,8 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
ObservationState::Fresh
);
let older_parent = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 1).unwrap();
let older_parent = client.refresh_observed(&session, "/", 1).unwrap();
let newer_child = client.refresh_observed(&session, "/child", 1).unwrap();
client
.complete(
newer_child.id,
@@ -1281,8 +1472,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap();
let newer = client.prepare_observe(&session, "/item", 0).unwrap();
let old = client.refresh_observed(&session, "/item", 0).unwrap();
let newer = client.refresh_observed(&session, "/item", 0).unwrap();
assert_eq!(
client
.fail_transport(old.id, "old connection failed")
@@ -1300,8 +1491,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
)
.unwrap();
let old = client.prepare_observe(&session, "/", 1).unwrap();
let newer_tree = client.prepare_observe(&session, "/", 1).unwrap();
let old = client.refresh_observed(&session, "/", 1).unwrap();
let newer_tree = client.refresh_observed(&session, "/", 1).unwrap();
assert_eq!(
client
.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"))
.unwrap();
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!(
client
.prepare_observe(&session, "/other", 0)
.refresh_observed(&session, "/other", 0)
.unwrap_err()
.kind,
ClientErrorKind::Capacity
@@ -1454,7 +1645,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
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!(
client
.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());
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!(
client
.fail_transport(request.id, "tree failed")
@@ -1487,8 +1678,8 @@ fn in_flight_and_history_are_bounded() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let first = client.prepare_observe(&session, "/one", 0).unwrap();
let error = client.prepare_observe(&session, "/two", 0).unwrap_err();
let first = client.refresh_observed(&session, "/one", 0).unwrap();
let error = client.refresh_observed(&session, "/two", 0).unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Capacity);
client
.complete(
@@ -1498,8 +1689,8 @@ fn in_flight_and_history_are_bounded() {
.unwrap();
// Diagnostic history evicts oldest entries at the configured bound.
let stale_old = client.prepare_observe(&session, "/one", 0).unwrap();
let stale_new = client.prepare_observe(&session, "/one", 0).unwrap_err();
let stale_old = client.refresh_observed(&session, "/one", 0).unwrap();
let stale_new = client.refresh_observed(&session, "/one", 0).unwrap_err();
assert_eq!(stale_new.kind, ClientErrorKind::Capacity);
client
.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
- **Worldspace tree** starts at `/` and materializes direct children only when a
node is expanded. Text labels distinguish unloaded, loading, empty, stale, and
failed state; color is supplemental. Every successful expansion is complete for
its requested depth.
- **Worldspace tree** starts at `/`, lazily materializes expanded nodes, and
prefetches children for each visible Object so emptiness and the next expansion
frontier are known without selecting that Object first. Text labels distinguish
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
interfaces, and freshness. Validators, credentials, Host internals, and backend
paths are deliberately hidden. If current metadata disappears, old text is not
+4 -3
View File
@@ -299,7 +299,9 @@ impl App {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
let mut entries = BTreeMap::new();
entries.insert("/".into(), EntryView::placeholder("/"));
let mut root = EntryView::placeholder("/");
root.expanded = true;
entries.insert("/".into(), root);
Self {
endpoint: endpoint.into(),
connection: ConnectionState::Disconnected,
@@ -556,7 +558,6 @@ impl App {
let entry = self.ensure_entry(&observation.path);
entry.children = children.clone();
entry.tree_state = ViewState::from(&observation.state);
entry.expanded = true;
for path in children {
self.ensure_entry(&path);
}
@@ -876,6 +877,6 @@ mod tests {
wip_client::SecurityContext::new("test"),
)
.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")
}
#[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]
fn snapshot_contains_non_color_state_markers_and_hides_validators() {
let mut app = App::new("https://example.test/wip/");
+29 -9
View File
@@ -304,7 +304,7 @@ impl<T: Transport> Runtime<T> {
/// Starts or restarts observation at Worldspace `/`.
pub fn connect(&mut self) {
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),
Err(error) => self.local_error("connect", error),
}
@@ -317,6 +317,9 @@ impl<T: Transport> Runtime<T> {
for action in actions {
quit |= self.perform(action);
}
if !quit {
self.reconcile_visible_lookahead();
}
quit
}
@@ -347,16 +350,14 @@ impl<T: Transport> Runtime<T> {
fn perform(&mut self, action: Action) -> bool {
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),
Err(error) => self.local_error("tree expansion", error),
},
Action::Refresh { path } => {
match self.client.prepare_observe(&self.session, &path, 1) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error),
}
}
Action::Refresh { path } => match self.client.refresh_children(&self.session, &path) {
Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false),
Err(error) => self.local_error("subtree refresh", error),
},
Action::FetchInterface(reference) => {
match self.client.prepare_interface(&self.session, &reference) {
Ok(request) => {
@@ -393,7 +394,7 @@ impl<T: Transport> Runtime<T> {
Action::Reconnect => self.connect(),
Action::NavigateEntry(path) => {
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),
Err(error) => self.local_error("Entry navigation", error),
}
@@ -403,6 +404,24 @@ impl<T: Transport> Runtime<T> {
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) {
let id = request.id;
self.app.request_started(id.get(), kind.clone());
@@ -567,6 +586,7 @@ impl<T: Transport> Runtime<T> {
}
}
self.reconcile_authoritative();
self.reconcile_visible_lookahead();
}
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("/nested"));
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.
runtime.app_mut().selected_path = "/hello.txt".into();