From 2e4196a1ec844e473d718019507a6e9a1e44bbce Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 13:44:54 +0900 Subject: [PATCH] fix: keep object observations fresh after successful calls --- crates/wip-client/src/lib.rs | 11 +- crates/wip-client/tests/runtime.rs | 146 +++++++++++++++++++++++-- crates/wip-tui/README.md | 4 +- crates/wip-tui/tests/filesystem_e2e.rs | 18 +-- 4 files changed, 153 insertions(+), 26 deletions(-) diff --git a/crates/wip-client/src/lib.rs b/crates/wip-client/src/lib.rs index 2465727..ca85723 100644 --- a/crates/wip-client/src/lib.rs +++ b/crates/wip-client/src/lib.rs @@ -1261,11 +1261,14 @@ impl Client { let session_id = context.session.clone(); let maximum = self.limits.history; let state = self.session_mut(&session_id)?; - if let Some(object) = state.objects.get_mut(&context.object_key) { - object.object.validator = value.validator.clone(); - object.revision = object.revision.max(id); + if let Some(object) = state.objects.get_mut(&context.object_key) + && object.revision <= id + { + if let Some(validator) = value.validator.clone() { + object.object.validator = Some(validator); + } + object.revision = id; } - state.mark_object_stale(context.object_key, id); let record = CallRecord { request_id: id, context, diff --git a/crates/wip-client/tests/runtime.rs b/crates/wip-client/tests/runtime.rs index d2c877f..98274a7 100644 --- a/crates/wip-client/tests/runtime.rs +++ b/crates/wip-client/tests/runtime.rs @@ -525,14 +525,147 @@ fn call_context_fixes_membership_validators_descriptor_and_result_validation() { client.interface(&session, "math").unwrap().descriptor, Some(descriptor(TypeExpr::String)) ); + let observation = client.object(&session, "/item").unwrap(); + assert_eq!(observation.state, ObservationState::Fresh); + assert_eq!(observation.validator, Some(b"object-v2".to_vec())); +} + +#[test] +fn successful_call_without_validator_preserves_observed_validator() { + let mut client = client(8, 8, 8, 8); + let session = client + .open_session("https://example.test/wip", SecurityContext::new("subject")) + .unwrap(); + observe_object( + &mut client, + &session, + "/item", + object("item", None, b"object-v1"), + ); + observe_interface( + &mut client, + &session, + FetchInterfaceResponse { + interface: "math".into(), + descriptor: descriptor(TypeExpr::Integer), + validator: None, + }, + ); + + let call = client + .prepare_call(&session, "/item", "math", "calculate", args(1)) + .unwrap(); + let request = client + .pending_call_context(call.id) + .unwrap() + .request() + .clone(); + let fixed_descriptor = client + .pending_call_context(call.id) + .unwrap() + .descriptor() + .clone(); + client.mark_dispatched(call.id).unwrap(); + client + .complete( + call.id, + encode_call_operation_response( + &request, + &fixed_descriptor, + &CallOperationResponse { + result: Value::Integer(2), + validator: None, + }, + wire_limits(), + ) + .unwrap(), + ) + .unwrap(); + + let observation = client.object(&session, "/item").unwrap(); + assert_eq!(observation.state, ObservationState::Fresh); + assert_eq!(observation.validator, Some(b"object-v1".to_vec())); + + let next = client + .prepare_call(&session, "/item", "math", "calculate", args(2)) + .unwrap(); assert_eq!( - client.object(&session, "/item").unwrap().state, - ObservationState::Stale + client + .pending_call_context(next.id) + .unwrap() + .request() + .target + .validator, + Some(b"object-v1".to_vec()) ); } #[test] -fn older_retrieval_cannot_restore_freshness_after_operation_completion() { +fn newer_observation_prevents_older_success_validator_overwrite() { + let mut client = client(8, 8, 8, 8); + let session = client + .open_session("https://example.test/wip", SecurityContext::new("subject")) + .unwrap(); + observe_object( + &mut client, + &session, + "/item", + object("item", None, b"object-v1"), + ); + observe_interface( + &mut client, + &session, + FetchInterfaceResponse { + interface: "math".into(), + descriptor: descriptor(TypeExpr::Integer), + validator: None, + }, + ); + + let call = client + .prepare_call(&session, "/item", "math", "calculate", args(1)) + .unwrap(); + let request = client + .pending_call_context(call.id) + .unwrap() + .request() + .clone(); + let fixed_descriptor = client + .pending_call_context(call.id) + .unwrap() + .descriptor() + .clone(); + client.mark_dispatched(call.id).unwrap(); + + observe_object( + &mut client, + &session, + "/item", + object("item", None, b"object-v3"), + ); + client + .complete( + call.id, + encode_call_operation_response( + &request, + &fixed_descriptor, + &CallOperationResponse { + result: Value::Integer(2), + validator: Some(b"object-v2".to_vec()), + }, + wire_limits(), + ) + .unwrap(), + ) + .unwrap(); + + let observation = client.object(&session, "/item").unwrap(); + assert_eq!(observation.state, ObservationState::Fresh); + assert_eq!(observation.validator, Some(b"object-v3".to_vec())); +} + +#[test] +fn older_retrieval_cannot_overwrite_success_validator() { let mut client = client(8, 8, 8, 8); let session = client .open_session("https://example.test/wip", SecurityContext::new("subject")) @@ -583,10 +716,9 @@ fn older_retrieval_cannot_restore_freshness_after_operation_completion() { .unwrap(), ) .unwrap(); - assert_eq!( - client.object(&session, "/item").unwrap().state, - ObservationState::Stale - ); + let current = client.object(&session, "/item").unwrap(); + assert_eq!(current.state, ObservationState::Fresh); + assert_eq!(current.validator, Some(b"object-v2".to_vec())); client .complete( diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index 5764ab4..571dba8 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -121,8 +121,8 @@ layout. ## Validator and failure behavior Object and interface validators are observed and attached automatically by -`wip-client`; users neither copy nor edit them. A successful operation conservatively -marks related object observations stale because metadata may have changed. +`wip-client`; users neither copy nor edit them. A successful operation may update the +cached Object validator but does not by itself make the Object observation stale. Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable post-dispatch responses, and post-dispatch timeout/disconnect are displayed using the runtime's authoritative transition. diff --git a/crates/wip-tui/tests/filesystem_e2e.rs b/crates/wip-tui/tests/filesystem_e2e.rs index 50b54a6..cbb2be2 100644 --- a/crates/wip-tui/tests/filesystem_e2e.rs +++ b/crates/wip-tui/tests/filesystem_e2e.rs @@ -91,11 +91,7 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { .contains("68656c6c6f20776f726c64") ); - // Calls conservatively stale the object even for a read operation; refresh - // before constructing the separately confirmed write call. - runtime.app_mut().focus = Focus::Tree; - runtime.handle_key(InputKey::Refresh); - settle(&mut runtime); + assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); runtime.app_mut().focus = Focus::Operations; // Load the separately-qualified write interface and replace with the @@ -112,15 +108,11 @@ fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { std::fs::read(temporary.path().join("hello.txt")).expect("updated file"), b"updated" ); - assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale); - - // Reobserve, prepare a replacement from that observation, then mutate the - // file externally before explicit execution. The stale validator is rejected, - // the Entry becomes stale, and no automatic call is created. - runtime.app_mut().focus = Focus::Tree; - runtime.handle_key(InputKey::Refresh); - settle(&mut runtime); assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); + + // Prepare another replacement using the post-operation validator, then mutate + // the file externally before explicit execution. The stale validator is rejected, + // the Entry becomes stale, and no automatic call is created. runtime.app_mut().focus = Focus::Operations; runtime.app_mut().operation_cursor = 3; runtime.handle_key(InputKey::Enter);