fix: wip bind live ancestor scopes and adopt 0.2.0

This commit is contained in:
2026-10-09 01:00:12 +09:00
parent 85c9b218dd
commit 23cf6c4f88
62 changed files with 746 additions and 17152 deletions
+4 -3
View File
@@ -23,8 +23,8 @@ use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_ident
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
json_to_wip, wip_to_json,
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
WipSubtreeProvider, json_to_wip, wip_to_json,
};
const ROOT: &str = "/checkouts";
@@ -288,7 +288,7 @@ impl Provider {
#[async_trait]
impl WipSubtreeProvider for Arc<Provider> {
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await
.map_err(|_| {
@@ -306,6 +306,7 @@ impl WipSubtreeProvider for Arc<Provider> {
"checkout observation deadline",
)
})?
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
@@ -185,16 +185,8 @@ async fn observe(
Ok(result)
}
#[async_trait]
impl WipSubtreeProvider for ConfigProvider {
fn max_depth(&self) -> u32 {
MAX_DEPTH
}
fn max_nodes(&self) -> usize {
MAX_NODES
}
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
impl ConfigProvider {
async fn projection_at(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
let source = source_path(path).map_err(protocol)?;
let Some(attachment) = visible_metadata(self.backend.current_async().await)? else {
return Ok(None);
@@ -259,6 +251,26 @@ impl WipSubtreeProvider for ConfigProvider {
}),
}))
}
}
#[async_trait]
impl WipSubtreeProvider for ConfigProvider {
fn max_depth(&self) -> u32 {
MAX_DEPTH
}
fn max_nodes(&self) -> usize {
MAX_NODES
}
async fn publication(
&self,
path: &str,
) -> Result<Option<crate::wip::WipPublication>, ProtocolError> {
self.projection_at(path)
.await?
.map(crate::wip::WipPublication::self_scoped)
.transpose()
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let source = source_path(path).map_err(protocol)?;
+159 -76
View File
@@ -242,6 +242,37 @@ pub struct WipDynamicMount {
pub resolver: Arc<dyn WipDynamicItemResolver>,
}
/// A provider-bound publication snapshot. The selected Object, Descriptor,
/// validator, handler and ancestor scope Object must come from one publication
/// boundary. `scope` is the current Object at `projection.interface.scope`, not
/// registration metadata. Absence is legitimate (and rejects before dispatch),
/// even when the scope has no public ref.
///
/// Providers must end old Interface registrations on scope deletion/replacement
/// and stop publishing orphan descendants. A captured handler still revalidates
/// authorization and domain preconditions at its existing execution/commit boundary.
/// Scopes outside the subtree are supplied by the owning Host, not the provider.
pub struct WipPublication {
pub projection: WipProjection,
pub scope: Option<Object>,
}
impl WipPublication {
/// Self scope uses the exact Object that supplied the Descriptor/handler.
pub fn self_scoped(projection: WipProjection) -> Result<Self, ProtocolError> {
if projection.interface.scope != projection.route {
return Err(protocol_error(
ProtocolErrorCode::Internal,
"ancestor scope requires a coherent provider publication snapshot",
));
}
Ok(Self {
scope: Some(projection.object.clone()),
projection,
})
}
}
/// Explicit arbitrary-depth request-time subtree provider. Unlike item families,
/// this resolves both files and directories and enumerates authorized children.
#[async_trait]
@@ -253,7 +284,10 @@ pub trait WipSubtreeProvider: Send + Sync {
fn max_nodes(&self) -> usize {
1024
}
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError>;
/// Resolve target and scope together. Do not construct this by independently
/// looking up a mutable ancestor after resolving the target. Self-scoped
/// publishers can use `WipPublication::self_scoped` on their pinned snapshot.
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError>;
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError>;
}
@@ -478,10 +512,11 @@ impl WipMountRegistry {
});
}
}
if self.subtree_mounts.iter().any(|mount| {
projection.route == mount.root
|| projection.route.starts_with(&format!("{}/", mount.root))
}) {
if self
.subtree_mounts
.iter()
.any(|mount| at_or_below(&projection.route, &mount.root))
{
return Err(WipMountError::RouteCollision {
route: projection.route,
existing: "request-time subtree".into(),
@@ -676,9 +711,8 @@ impl WipMountRegistry {
}
})?;
if self.subtree_mounts.iter().any(|m| {
m.root == mount.collection_route
|| m.root.starts_with(&format!("{}/", mount.collection_route))
|| mount.collection_route.starts_with(&format!("{}/", m.root))
at_or_below(&m.root, &mount.collection_route)
|| at_or_below(&mount.collection_route, &m.root)
}) {
return Err(WipMountError::RouteCollision {
route: mount.collection_route,
@@ -750,7 +784,8 @@ impl WipMountRegistry {
}
/// Delegate an entire descendant namespace to one request-time provider.
/// The root Object remains registry-owned; nested/static/item collisions fail.
/// The registry reserves root ownership; its live Object is provider-owned.
/// Nested/static/item collisions fail.
pub fn mount_subtree(&mut self, mount: WipSubtreeMount) -> Result<(), WipMountError> {
let Some(root) = self.mounts.get(&mount.root) else {
return Err(WipMountError::OperationTargetNotFound { route: mount.root });
@@ -761,11 +796,8 @@ impl WipMountRegistry {
message: "subtree requires a native root Object".into(),
});
}
let overlaps = |path: &str| {
path == mount.root
|| path.starts_with(&format!("{}/", mount.root))
|| mount.root.starts_with(&format!("{path}/"))
};
let overlaps =
|path: &str| at_or_below(path, &mount.root) || at_or_below(&mount.root, path);
if self.subtree_mounts.iter().any(|m| overlaps(&m.root))
|| self
.dynamic_mounts
@@ -774,7 +806,7 @@ impl WipMountRegistry {
|| self
.mounts
.keys()
.any(|p| p != &mount.root && p.starts_with(&format!("{}/", mount.root)))
.any(|p| p != &mount.root && at_or_below(p, &mount.root))
{
return Err(WipMountError::RouteCollision {
route: mount.root,
@@ -1108,16 +1140,26 @@ impl WipHost {
self.registry
.subtree_mounts
.iter()
.find(|m| path == m.root || path.starts_with(&format!("{}/", m.root)))
.find(|m| at_or_below(path, &m.root))
}
async fn projection_live(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
fn static_publication(&self, projection: WipProjection) -> WipPublication {
let scope = if projection.interface.scope == projection.route {
Some(projection.object.clone())
} else {
self.object_at(&projection.interface.scope)
};
WipPublication { projection, scope }
}
async fn publication_live(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
wip_protocol::validate_path(path).map_err(|_| unpublished_path_error(path))?;
if let Some(mount) = self.subtree(path) {
let Some(projection) = mount.provider.projection(path).await? else {
let Some(mut publication) = mount.provider.publication(path).await? else {
return Ok(None);
};
validate_projection(&projection).map_err(|_| {
let projection = &publication.projection;
validate_projection(projection).map_err(|_| {
protocol_error(
ProtocolErrorCode::Internal,
"subtree returned an invalid Object",
@@ -1133,20 +1175,56 @@ impl WipHost {
"subtree changed route or ownership",
));
}
return Ok(Some(projection));
let scope_path = &projection.interface.scope;
if self
.subtree(scope_path)
.is_some_and(|scope_owner| scope_owner.root == mount.root)
{
if let Some(scope) = &publication.scope {
scope.validate_at_path(scope_path).map_err(|_| {
protocol_error(
ProtocolErrorCode::Internal,
"subtree returned an invalid scope Object",
)
})?;
if scope_path == path && scope != &projection.object {
return Err(protocol_error(
ProtocolErrorCode::Internal,
"self scope differs from the selected Object snapshot",
));
}
}
} else {
// The Host owns the pinned external ancestor (e.g. `/`). Never
// accept provider-supplied identity for somebody else's scope.
publication.scope = self.object_at(scope_path);
}
return Ok(Some(publication));
}
Ok(self.projection(path))
Ok(self
.projection(path)
.map(|projection| self.static_publication(projection)))
}
async fn projection_for_interface_live(
async fn projection_live(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
Ok(self
.publication_live(path)
.await?
.filter(|publication| publication.scope.is_some())
.map(|publication| publication.projection))
}
async fn publication_for_interface_live(
&self,
path: &str,
reference: &InterfaceReference,
) -> Result<Option<WipProjection>, ProtocolError> {
let Some(owner) = self.projection_live(path).await? else {
) -> Result<Option<WipPublication>, ProtocolError> {
let Some(owner) = self.publication_live(path).await? else {
return Ok(None);
};
if owner.interface == *reference {
// A live provider owns all its publication metadata; static additional
// Interface registrations must not survive its scope lifetime changes.
if owner.projection.interface == *reference || self.subtree(path).is_some() {
return Ok(Some(owner));
}
if let Some(all) = self.registry.additional_interfaces.get(path) {
@@ -1154,38 +1232,15 @@ impl WipHost {
if let Some(mut selected) = self.project_current(other.clone(), false)
&& selected.interface == *reference
{
selected.object = owner.object;
return Ok(Some(selected));
selected.object = owner.projection.object;
return Ok(Some(self.static_publication(selected)));
}
}
}
// Retain the owner so preconditions report InterfaceMismatch with target precedence.
// Retain the target so preconditions preserve target/validator precedence.
Ok(Some(owner))
}
async fn descriptor_live(
&self,
reference: &InterfaceReference,
) -> Result<Option<(InterfaceDescriptor, Option<Vec<u8>>)>, ProtocolError> {
if self.subtree(&reference.scope).is_some() {
return Ok(self
.projection_live(&reference.scope)
.await?
.filter(|p| p.interface == *reference)
.map(|p| (p.descriptor, p.interface_validator)));
}
// Never expose the static registration placeholder descriptor of a subtree.
if self
.registry
.subtree_mounts
.iter()
.any(|m| self.registry.mounts[&m.root].projection.interface == *reference)
{
return Ok(None);
}
Ok(self.descriptor(reference))
}
async fn fetch_interface_live(
&self,
reference: &InterfaceReference,
@@ -1193,18 +1248,33 @@ impl WipHost {
reference
.validate()
.map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?;
if let Some(projection) = self
.projection_for_interface_live(&reference.scope, reference)
if let Some(publication) = self
.publication_for_interface_live(&reference.scope, reference)
.await?
&& projection.interface == *reference
&& publication.projection.interface == *reference
&& let Some(scope) = publication.scope
{
return Ok(FetchInterfaceResponse {
interface: reference.clone(),
scope_ref: projection.object.r#ref,
descriptor: projection.descriptor,
validator: projection.interface_validator,
scope_ref: scope.r#ref,
descriptor: publication.projection.descriptor,
validator: publication.projection.interface_validator,
});
}
// A provider owns the whole live scope observation. Never fall back to
// its registration placeholder after it stops publishing a scope/name.
if self.subtree(&reference.scope).is_some()
|| self
.registry
.subtree_mounts
.iter()
.any(|mount| self.registry.mounts[&mount.root].projection.interface == *reference)
{
return Err(protocol_error(
ProtocolErrorCode::InterfaceNotFound,
"interface is not published",
));
}
// Shared static registrations belong to the pinned published scope, not
// to an arbitrary target's identity. A missing scope ends publication.
let scope = self.object_at(&reference.scope).ok_or_else(|| {
@@ -1213,7 +1283,7 @@ impl WipHost {
"scope is not published",
)
})?;
let (descriptor, validator) = self.descriptor_live(reference).await?.ok_or_else(|| {
let (descriptor, validator) = self.descriptor(reference).ok_or_else(|| {
protocol_error(
ProtocolErrorCode::InterfaceNotFound,
"interface is not published",
@@ -1289,7 +1359,7 @@ impl WipHost {
let paths = if let Some(mount) = self.subtree(path) {
mount.provider.children(path).await?
} else {
self.children(path)
self.children_live(path).await?
};
let mut values = Vec::new();
let mut seen = BTreeSet::new();
@@ -1513,6 +1583,16 @@ impl WipHost {
.any(|route| route.starts_with(&prefix) && self.projection(route).is_some())
}
async fn children_live(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let mut visible = Vec::new();
for child in self.children(path) {
if self.subtree(&child).is_none() || self.projection_live(&child).await?.is_some() {
visible.push(child);
}
}
Ok(visible)
}
fn children(&self, path: &str) -> Vec<String> {
let prefix = if path == WIP_ROOT {
"/".to_string()
@@ -1593,7 +1673,7 @@ impl WipHost {
))
})?;
let projection = self
.projection_for_interface_live(&request.target.path, &request.interface.reference)
.publication_for_interface_live(&request.target.path, &request.interface.reference)
.await
.map_err(WipOperationError::Protocol)?
.ok_or_else(|| {
@@ -1602,33 +1682,29 @@ impl WipHost {
self.call_projection(projection, request, context).await
}
// The deployment root is pinned; self-scoped interfaces use the exact same
// resolved Object snapshot as descriptor/handler selection, not an artifact lookup.
// Both self and ancestor scopes were frozen at the same publication boundary
// as target/Descriptor/handler selection. Never re-resolve a mutable scope here.
fn scope_matches(
&self,
projection: &WipProjection,
publication: &WipPublication,
interface: &wip_protocol::InterfaceTarget,
) -> Result<bool, ProtocolError> {
let scope = if interface.reference.scope == projection.route {
Some(projection.object.clone())
} else {
self.object_at(&interface.reference.scope)
};
Ok(scope.is_some_and(|scope| {
) -> bool {
publication.scope.as_ref().is_some_and(|scope| {
interface
.scope_ref
.as_ref()
.is_none_or(|expected| scope.r#ref.as_ref() == Some(expected))
}))
})
}
fn check_call_preconditions(
&self,
projection: &WipProjection,
publication: &WipPublication,
target: &wip_protocol::Target,
interface: &wip_protocol::InterfaceTarget,
operation_name: &str,
) -> Result<(), ProtocolError> {
let projection = &publication.projection;
if let Some(actual) = target.validator.as_deref()
&& projection.object.validator.as_deref() != Some(actual)
{
@@ -1640,7 +1716,7 @@ impl WipHost {
if interface.reference.validate_for_path(&target.path).is_err()
|| interface.reference != projection.interface
|| !projection.object.interfaces.contains(&interface.reference)
|| !self.scope_matches(projection, interface)?
|| !self.scope_matches(publication, interface)
{
return Err(protocol_error(
ProtocolErrorCode::InterfaceMismatch,
@@ -1685,9 +1761,10 @@ impl WipHost {
Ok(())
}
#[cfg(test)]
async fn call_projection(
&self,
projection: WipProjection,
projection: WipPublication,
request: CallOperationRequest,
context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
@@ -1704,7 +1781,8 @@ impl WipHost {
&request.operation,
)
.map_err(WipOperationError::Protocol)?;
self.execute_projection(projection, request, context).await
self.execute_projection(projection.projection, request, context)
.await
}
// Preconditions have already fixed publication; do not resolve target or
@@ -1731,6 +1809,10 @@ impl WipHost {
}
}
fn at_or_below(path: &str, ancestor: &str) -> bool {
ancestor == "/" || path == ancestor || path.starts_with(&format!("{ancestor}/"))
}
fn unpublished_path_error(path: &str) -> ProtocolError {
let message = if path == "/features" || path.starts_with("/features/") {
"legacy /features/... routes are no longer published; rediscover from `/` and use the root namespace"
@@ -2287,7 +2369,7 @@ impl WipRuntime {
};
let projection = match self
.host
.projection_for_interface_live(&metadata.target.path, &metadata.interface.reference)
.publication_for_interface_live(&metadata.target.path, &metadata.interface.reference)
.await
{
Ok(Some(projection)) => projection,
@@ -2302,6 +2384,7 @@ impl WipRuntime {
) {
return rejected(error);
}
let projection = projection.projection;
let descriptor = projection.descriptor.clone();
let target_validator = projection.object.validator.clone();
let request_value =
+459
View File
@@ -0,0 +1,459 @@
//! Regression coverage for MR P1_CURRENT_ANCESTOR_SCOPE, through real adapters.
use super::*;
struct State {
visible: bool,
identity: Option<String>,
name: String,
generation: usize,
target_validator: Option<Vec<u8>>,
lookups: Vec<String>,
}
struct AncestorProvider {
scope_path: String,
target_path: String,
state: Arc<Mutex<State>>,
calls: Arc<AtomicUsize>,
blocked: bool,
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
impl AncestorProvider {
fn scope_projection(&self, state: &State) -> WipProjection {
let mut p = projection(
&self.scope_path,
&self.scope_path,
&state.name,
self.calls.clone(),
);
p.object.r#ref = state.identity.clone();
self.version(p, state.generation)
}
fn version(&self, mut p: WipProjection, generation: usize) -> WipProjection {
p.descriptor.documentation = Some(Documentation {
summary: format!("Published generation {generation}"),
details: None,
});
if self.blocked {
p.interface_validator = Some(vec![generation as u8]);
if generation != 0 {
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
}
p.handler = Arc::new(FrozenResultHandler {
generation,
started: self.started.clone(),
release: self.release.clone(),
calls: self.calls.clone(),
});
}
p
}
}
#[async_trait]
impl WipSubtreeProvider for AncestorProvider {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
// One lock captures target, scope lifetime, descriptor/validator and
// handler. No separate scope lookup can splice new identity onto old data.
let mut state = self.state.lock().unwrap();
state.lookups.push(path.into());
if path == self.scope_path {
return if state.visible {
WipPublication::self_scoped(self.scope_projection(&state)).map(Some)
} else {
Ok(None)
};
}
if path == "/github" && self.scope_path != path {
return if state.visible {
WipPublication::self_scoped(projection(path, path, "root", self.calls.clone()))
.map(Some)
} else {
Ok(None)
};
}
if path != self.target_path {
return Ok(None);
}
let mut target = projection(path, &self.scope_path, &state.name, self.calls.clone());
target.object.validator = state.target_validator.clone();
let target = self.version(target, state.generation);
// Retain a target artifact after deleting the scope to exercise Host
// fail-closed behavior, not merely a provider that already hides orphans.
let scope = state.visible.then(|| self.scope_projection(&state).object);
Ok(Some(WipPublication {
projection: target,
scope,
}))
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let state = self.state.lock().unwrap();
if !state.visible {
return Ok(vec![]);
}
if path == self.scope_path {
return Ok(vec![self.target_path.clone()]);
}
if path == "/github" && path != self.scope_path {
return Ok(vec![self.scope_path.clone()]);
}
Ok(vec![])
}
}
fn fixture(scope: &str, with_ref: bool, blocked: bool) -> (WipRuntime, Arc<AncestorProvider>) {
let provider = Arc::new(AncestorProvider {
scope_path: scope.into(),
target_path: if scope == "/" {
"/item".into()
} else {
format!("{scope}/item")
},
state: Arc::new(Mutex::new(State {
visible: true,
identity: with_ref.then(|| "live-scope".into()),
name: "read".into(),
generation: 0,
target_validator: None,
lookups: vec![],
})),
calls: Arc::new(AtomicUsize::new(0)),
blocked,
started: Arc::new(tokio::sync::Notify::new()),
release: Arc::new(tokio::sync::Notify::new()),
});
let mut mounts = WipMountRegistry::new();
mounts.allocate_namespace("scope", "github").unwrap();
let mount_root = if scope == "/" { "/" } else { "/github" };
let mut placeholder = projection(mount_root, mount_root, "read", provider.calls.clone());
placeholder.object.r#ref = Some("registration-placeholder".into());
mounts.mount(placeholder).unwrap();
mounts
.mount_subtree(WipSubtreeMount {
root: mount_root.into(),
provider: provider.clone(),
})
.unwrap();
(
WipRuntime::from_mounts(mounts, "ancestor-test".into()).unwrap(),
provider,
)
}
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: name.into(),
}
}
#[tokio::test]
async fn live_root_and_intermediate_ancestors_use_one_current_publication_not_placeholder() {
for scope in ["/", "/github", "/github/team"] {
for with_ref in [true, false] {
let (runtime, provider) = fixture(scope, with_ref, false);
let selected = reference(scope, "read");
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
assert_eq!(fetched.scope_ref, with_ref.then(|| "live-scope".into()));
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().lookups.clear();
runtime
.invoke(
provider.target_path.clone(),
selected,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
assert_eq!(
provider.state.lock().unwrap().lookups,
[provider.target_path.clone()]
);
let state = runtime.state.lock().unwrap();
let call = state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap();
assert_eq!(
call.context.request().interface.scope_ref,
fetched.scope_ref
);
drop(state);
// A specified ref must still match even when the current scope has none.
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":scope,"name":"read"}),
None,
Some("wrong")
)
)
.await,
"interface_mismatch"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
}
}
#[tokio::test]
async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_observation() {
for scope in ["/github", "/github/team"] {
for with_ref in [true, false] {
let (runtime, provider) = fixture(scope, with_ref, false);
let selected = reference(scope, "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().visible = false;
let failed = runtime
.invoke(
provider.target_path.clone(),
selected.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
assert!(
runtime
.inspect(provider.target_path.clone(), true)
.await
.is_err()
);
assert!(
runtime
.tree(provider.target_path.clone(), 0, true)
.await
.is_err()
);
assert_eq!(
runtime
.host
.fetch_interface_live(&selected)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let root = runtime.tree("/".into(), 3, true).await.unwrap();
assert!(!root.content.unwrap().contains("/github"));
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
}
}
}
#[tokio::test]
async fn republishing_scope_never_restores_old_registration_even_without_refs() {
for with_ref in [true, false] {
let (runtime, provider) = fixture("/github", with_ref, false);
let old = reference("/github", "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().visible = false;
assert_eq!(
runtime
.host
.fetch_interface_live(&old)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
{
let mut state = provider.state.lock().unwrap();
state.visible = true;
state.identity = with_ref.then(|| "replacement".into());
state.name = "new".into();
state.generation = 1;
}
// The old root registration and cached old descriptor both still exist.
assert_eq!(
runtime
.host
.fetch_interface_live(&old)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
assert!(
runtime
.invoke(
provider.target_path.clone(),
old,
"read".into(),
json!({}),
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
runtime
.inspect(provider.target_path.clone(), true)
.await
.unwrap();
let new = reference("/github", "new");
assert_eq!(
runtime
.host
.fetch_interface_live(&new)
.await
.unwrap()
.scope_ref,
with_ref.then(|| "replacement".into())
);
runtime
.invoke(
provider.target_path.clone(),
new,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn missing_ancestor_preserves_wire_shape_target_and_object_validator_precedence() {
let (runtime, provider) = fixture("/github", false, false);
{
let mut state = provider.state.lock().unwrap();
state.visible = false;
state.target_validator = Some(vec![1]);
}
assert_eq!(
rejected_code(
&runtime,
metadata_request("/missing", json!("old"), None, None)
)
.await,
"invalid_request"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/missing",
json!({"scope":"/github","name":"read"}),
Some("AA=="),
None
)
)
.await,
"not_found"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":"/github","name":"read"}),
Some("AA=="),
None
)
)
.await,
"validator_mismatch"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":"/github","name":"read"}),
Some("AQ=="),
None
)
)
.await,
"interface_mismatch"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descriptor_and_handler() {
let (runtime, provider) = fixture("/github/team", true, true);
let selected = reference("/github/team", "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
let pending = runtime.invoke(
provider.target_path.clone(),
selected.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
);
let update = async {
provider.started.notified().await;
{
let mut state = provider.state.lock().unwrap();
state.identity = Some("replacement".into());
state.generation = 1;
}
let updated = runtime
.inspect(provider.target_path.clone(), true)
.await
.unwrap();
assert!(updated.content.unwrap().contains("integer"));
provider.release.notify_one();
};
let (old, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(pending, update)
})
.await
.unwrap();
assert_eq!(old.unwrap().content.as_deref(), Some("\"old result\""));
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
let state = runtime.state.lock().unwrap();
let history = state.client.call_history(&state.session).unwrap();
let old = history.back().unwrap();
assert_eq!(
old.context.request().interface.scope_ref.as_deref(),
Some("live-scope")
);
assert_eq!(
old.context.descriptor().operations[0].returns.r#type,
TypeExpr::String
);
drop(state);
let new = runtime
.invoke(
provider.target_path.clone(),
selected,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(new.content.as_deref(), Some("2"));
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
}
+5 -2
View File
@@ -2,6 +2,9 @@
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize};
#[path = "ancestor_tests.rs"]
mod ancestor_tests;
struct ReadHandler(Arc<AtomicUsize>);
#[async_trait]
impl WipOperationHandler for ReadHandler {
@@ -464,7 +467,7 @@ impl WipOperationHandler for FrozenResultHandler {
}
#[async_trait]
impl WipSubtreeProvider for ChangingProvider {
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
if path != "/github" {
return Ok(None);
}
@@ -480,7 +483,7 @@ impl WipSubtreeProvider for ChangingProvider {
release: self.release.clone(),
calls: self.calls.clone(),
});
Ok(Some(p))
WipPublication::self_scoped(p).map(Some)
}
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
Ok(vec![])