feat(worker): project objectives as native WIP objects
This commit is contained in:
@@ -1551,13 +1551,16 @@ where
|
||||
}
|
||||
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
{
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
// Objective tools expose read-only project Objective context through the
|
||||
// Backend Workspace API. Workers must not guess local `.yoi/objectives`
|
||||
// paths or read Objective files directly.
|
||||
// Objective tools expose scoped project Objective reads and mutations through
|
||||
// the Backend Workspace API. Workers must not guess local `.yoi/objectives`
|
||||
// paths or read/edit Objective files directly.
|
||||
if feature_config.objective.enabled {
|
||||
if workspace_client.is_available() && workspace_client.workspace_id().is_some() {
|
||||
for definition in crate::feature::builtin::objective::workspace_http_objective_tools(
|
||||
@@ -1565,6 +1568,28 @@ where
|
||||
) {
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
if wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("objective")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Objective WIP route: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
|
||||
&mut wip_mount_registry,
|
||||
workspace_client.clone(),
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Objective WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
@@ -1631,22 +1656,25 @@ where
|
||||
if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution {
|
||||
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||
}
|
||||
if worker.manifest().worker.mode == manifest::WorkerMode::Wip {
|
||||
let permissions = worker.manifest().permissions.clone();
|
||||
if wip_mode {
|
||||
let security_context = format!(
|
||||
"workspace={};worker={};session={}",
|
||||
worker.workspace_id().map_or("standalone", |id| id.as_str()),
|
||||
worker.manifest().worker.name,
|
||||
worker.session_id()
|
||||
);
|
||||
crate::wip::install_wip_mode(worker.engine_mut(), permissions, security_context).map_err(
|
||||
|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("install WIP Worker surface: {error}"),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
crate::wip::install_wip_mode_with_mounts(
|
||||
worker.engine_mut(),
|
||||
wip_permissions,
|
||||
security_context,
|
||||
wip_mount_registry,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("install WIP Worker surface: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
worker.attach_tracker(tracker);
|
||||
Ok(workdir_for_view)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,9 @@ pub(super) struct ModelObjectiveDetail {
|
||||
objective: String,
|
||||
title: String,
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
state: String,
|
||||
revision: String,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
linked_tickets: Vec<ModelTicketSummary>,
|
||||
@@ -86,12 +88,6 @@ pub(super) struct ModelObjectiveDetail {
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
impl ModelObjectiveDetail {
|
||||
pub(super) fn objective_ref(&self) -> &str {
|
||||
&self.objective
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
@@ -363,7 +359,9 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
|
||||
objective: resource_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
body_truncated: bool_field(root, "body_truncated")?,
|
||||
state: string_field(root, "state")?,
|
||||
revision: string_field(root, "revision")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
updated_at: optional_string(root, "updated_at")?,
|
||||
linked_tickets: array_field(root, "linked_ticket_summaries")?
|
||||
@@ -671,7 +669,9 @@ mod tests {
|
||||
"resource_key": "O-543",
|
||||
"title": "Objective",
|
||||
"body": "Body",
|
||||
"body_truncated": false,
|
||||
"state": "active",
|
||||
"revision": "rev-3",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z",
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
@@ -695,6 +695,8 @@ mod tests {
|
||||
let text = json.to_string();
|
||||
assert!(text.contains("O-543"));
|
||||
assert!(text.contains("T-496"));
|
||||
assert_eq!(json["revision"], "rev-3");
|
||||
assert_eq!(json["body_truncated"], false);
|
||||
assert!(!text.contains("00001M10HW6BV"));
|
||||
assert!(!text.contains("00001M0E82D1V"));
|
||||
assert!(!text.contains("event_ref"));
|
||||
|
||||
+427
-18
@@ -127,6 +127,7 @@ pub trait WipOperationHandler: Send + Sync {
|
||||
|
||||
/// One route contribution. Native projections replace compatibility projections
|
||||
/// only when both declare the same semantic capability; unrelated collisions fail.
|
||||
#[derive(Clone)]
|
||||
pub struct WipProjection {
|
||||
pub route: String,
|
||||
pub capability: String,
|
||||
@@ -138,6 +139,55 @@ pub struct WipProjection {
|
||||
pub handler: Arc<dyn WipOperationHandler>,
|
||||
}
|
||||
|
||||
/// One dynamically resolved direct-child object beneath a Feature-owned
|
||||
/// collection route. The Host retains route allocation and descriptor authority;
|
||||
/// the Feature only binds a validated child segment to an object and handler.
|
||||
pub struct WipDynamicItem {
|
||||
pub object: Object,
|
||||
pub handler: Arc<dyn WipOperationHandler>,
|
||||
}
|
||||
|
||||
pub trait WipDynamicItemResolver: Send + Sync {
|
||||
fn resolve(&self, item_reference: &str) -> Option<WipDynamicItem>;
|
||||
}
|
||||
|
||||
pub struct WipDynamicMount {
|
||||
pub collection_route: String,
|
||||
pub capability: String,
|
||||
pub interface: String,
|
||||
pub descriptor: InterfaceDescriptor,
|
||||
pub interface_validator: Option<Vec<u8>>,
|
||||
pub resolver: Arc<dyn WipDynamicItemResolver>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WipFeatureRoute {
|
||||
root: String,
|
||||
}
|
||||
|
||||
impl WipFeatureRoute {
|
||||
pub fn root(&self) -> &str {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn child(&self, segment: &str) -> Result<String, WipMountError> {
|
||||
if segment.is_empty()
|
||||
|| segment == "."
|
||||
|| segment == ".."
|
||||
|| segment.contains('/')
|
||||
|| !segment
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err(WipMountError::InvalidRoute {
|
||||
route: format!("{}/{}", self.root, segment),
|
||||
message: "Feature child must be one canonical path segment".into(),
|
||||
});
|
||||
}
|
||||
Ok(format!("{}/{}", self.root, segment))
|
||||
}
|
||||
}
|
||||
|
||||
struct MountedProjection {
|
||||
projection: WipProjection,
|
||||
}
|
||||
@@ -145,6 +195,9 @@ struct MountedProjection {
|
||||
#[derive(Default)]
|
||||
pub struct WipMountRegistry {
|
||||
mounts: BTreeMap<String, MountedProjection>,
|
||||
dynamic_mounts: Vec<WipDynamicMount>,
|
||||
feature_routes: BTreeSet<String>,
|
||||
replaced_compatibility_capabilities: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl WipMountRegistry {
|
||||
@@ -152,11 +205,47 @@ impl WipMountRegistry {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Allocate the Host namespace owned by one enabled Feature. Features join
|
||||
/// checked relative segments to this route instead of selecting global paths.
|
||||
pub fn allocate_feature_route(
|
||||
&mut self,
|
||||
feature: &str,
|
||||
) -> Result<WipFeatureRoute, WipMountError> {
|
||||
if feature.is_empty()
|
||||
|| !feature
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
{
|
||||
return Err(WipMountError::InvalidRoute {
|
||||
route: format!("/features/{feature}"),
|
||||
message: "Feature name must use lowercase ASCII letters, digits, or '-'".into(),
|
||||
});
|
||||
}
|
||||
let root = format!("/features/{feature}");
|
||||
if !self.feature_routes.insert(root.clone()) {
|
||||
return Err(WipMountError::RouteCollision {
|
||||
route: root,
|
||||
existing: format!("feature:{feature}"),
|
||||
});
|
||||
}
|
||||
Ok(WipFeatureRoute { root })
|
||||
}
|
||||
|
||||
pub fn mount(
|
||||
&mut self,
|
||||
projection: WipProjection,
|
||||
) -> Result<WipMountDisposition, WipMountError> {
|
||||
validate_projection(&projection)?;
|
||||
if self.dynamic_mounts.iter().any(|dynamic| {
|
||||
projection
|
||||
.route
|
||||
.starts_with(&format!("{}/", dynamic.collection_route))
|
||||
}) {
|
||||
return Err(WipMountError::RouteCollision {
|
||||
route: projection.route,
|
||||
existing: "dynamic Feature collection".into(),
|
||||
});
|
||||
}
|
||||
if let Some(existing) = self.mounts.get(&projection.route) {
|
||||
if existing.projection.capability != projection.capability {
|
||||
return Err(WipMountError::RouteCollision {
|
||||
@@ -188,16 +277,150 @@ impl WipMountRegistry {
|
||||
Ok(WipMountDisposition::Mounted)
|
||||
}
|
||||
|
||||
/// Mount a one-segment item family below an already mounted collection.
|
||||
/// Dynamic resolution never creates authority: the collection route must be
|
||||
/// native and every resolved child is validated before it is exposed.
|
||||
pub fn mount_dynamic(&mut self, mount: WipDynamicMount) -> Result<(), WipMountError> {
|
||||
wip_protocol::validate_path(&mount.collection_route).map_err(|error| {
|
||||
WipMountError::InvalidRoute {
|
||||
route: mount.collection_route.clone(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let Some(collection) = self.mounts.get(&mount.collection_route) else {
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: mount.collection_route,
|
||||
message: "dynamic item mount requires an existing collection projection".into(),
|
||||
});
|
||||
};
|
||||
if collection.projection.kind != WipProjectionKind::Native {
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: mount.collection_route,
|
||||
message: "dynamic item mount requires a native collection projection".into(),
|
||||
});
|
||||
}
|
||||
mount
|
||||
.descriptor
|
||||
.validate()
|
||||
.map_err(|error| WipMountError::InvalidProjection {
|
||||
route: mount.collection_route.clone(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if let Some(existing) = self.dynamic_mounts.iter().find(|existing| {
|
||||
existing.collection_route == mount.collection_route
|
||||
|| mount
|
||||
.collection_route
|
||||
.starts_with(&format!("{}/", existing.collection_route))
|
||||
|| existing
|
||||
.collection_route
|
||||
.starts_with(&format!("{}/", mount.collection_route))
|
||||
}) {
|
||||
return Err(WipMountError::RouteCollision {
|
||||
route: mount.collection_route,
|
||||
existing: existing.capability.clone(),
|
||||
});
|
||||
}
|
||||
if let Some((_, existing)) = self
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|(route, _)| route.starts_with(&format!("{}/", mount.collection_route)))
|
||||
{
|
||||
return Err(WipMountError::RouteCollision {
|
||||
route: mount.collection_route,
|
||||
existing: existing.projection.capability.clone(),
|
||||
});
|
||||
}
|
||||
if self.interface_conflicts(
|
||||
&mount.interface,
|
||||
&mount.descriptor,
|
||||
mount.interface_validator.as_deref(),
|
||||
None,
|
||||
) {
|
||||
return Err(WipMountError::InterfaceCollision {
|
||||
interface: mount.interface,
|
||||
});
|
||||
}
|
||||
self.dynamic_mounts.push(mount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Declare ordinary tool capabilities fully represented by native mounts.
|
||||
/// Claimed compatibility projections are removed from WIP mode while the
|
||||
/// normal Tool-mode registration remains unchanged.
|
||||
pub fn replace_compatibility_tools<'a>(
|
||||
&mut self,
|
||||
owner_route: &str,
|
||||
tool_names: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<(), WipMountError> {
|
||||
if !self.mounts.contains_key(owner_route)
|
||||
&& !self
|
||||
.dynamic_mounts
|
||||
.iter()
|
||||
.any(|mount| mount.collection_route == owner_route)
|
||||
{
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: owner_route.to_string(),
|
||||
message: "compatibility replacement owner is not mounted".into(),
|
||||
});
|
||||
}
|
||||
let capabilities = tool_names
|
||||
.into_iter()
|
||||
.map(|name| format!("tool:{name}"))
|
||||
.collect::<Vec<_>>();
|
||||
for capability in &capabilities {
|
||||
if let Some(existing) = self
|
||||
.replaced_compatibility_capabilities
|
||||
.get(capability)
|
||||
.filter(|existing| existing.as_str() != owner_route)
|
||||
{
|
||||
return Err(WipMountError::RouteCollision {
|
||||
route: owner_route.to_string(),
|
||||
existing: existing.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for capability in capabilities {
|
||||
self.replaced_compatibility_capabilities
|
||||
.insert(capability, owner_route.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replaces_compatibility(&self, capability: &str) -> bool {
|
||||
self.replaced_compatibility_capabilities
|
||||
.contains_key(capability)
|
||||
}
|
||||
|
||||
fn interface_conflicts(
|
||||
&self,
|
||||
interface: &str,
|
||||
descriptor: &InterfaceDescriptor,
|
||||
validator: Option<&[u8]>,
|
||||
replacing_route: Option<&str>,
|
||||
) -> bool {
|
||||
self.mounts.iter().any(|(route, mounted)| {
|
||||
Some(route.as_str()) != replacing_route
|
||||
&& mounted.projection.interface == interface
|
||||
&& (mounted.projection.descriptor != *descriptor
|
||||
|| mounted.projection.interface_validator.as_deref() != validator)
|
||||
}) || self.dynamic_mounts.iter().any(|mounted| {
|
||||
mounted.interface == interface
|
||||
&& (mounted.descriptor != *descriptor
|
||||
|| mounted.interface_validator.as_deref() != validator)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_interface_available(
|
||||
&self,
|
||||
candidate: &WipProjection,
|
||||
replacing_route: Option<&str>,
|
||||
) -> Result<(), WipMountError> {
|
||||
if self.mounts.iter().any(|(route, mounted)| {
|
||||
Some(route.as_str()) != replacing_route
|
||||
&& mounted.projection.interface == candidate.interface
|
||||
&& mounted.projection.descriptor != candidate.descriptor
|
||||
}) {
|
||||
if self.interface_conflicts(
|
||||
&candidate.interface,
|
||||
&candidate.descriptor,
|
||||
candidate.interface_validator.as_deref(),
|
||||
replacing_route,
|
||||
) {
|
||||
return Err(WipMountError::InterfaceCollision {
|
||||
interface: candidate.interface.clone(),
|
||||
});
|
||||
@@ -274,13 +497,38 @@ impl WipHost {
|
||||
mounted.projection.interface_validator.as_deref(),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.registry
|
||||
.dynamic_mounts
|
||||
.iter()
|
||||
.find(|mounted| mounted.interface == reference)
|
||||
.map(|mounted| (&mounted.descriptor, mounted.interface_validator.as_deref()))
|
||||
})
|
||||
}
|
||||
|
||||
fn projection(&self, path: &str) -> Option<&WipProjection> {
|
||||
self.registry
|
||||
.mounts
|
||||
.get(path)
|
||||
.map(|mounted| &mounted.projection)
|
||||
fn projection(&self, path: &str) -> Option<WipProjection> {
|
||||
if let Some(mounted) = self.registry.mounts.get(path) {
|
||||
return Some(mounted.projection.clone());
|
||||
}
|
||||
self.registry.dynamic_mounts.iter().find_map(|mounted| {
|
||||
let item_reference = path.strip_prefix(&format!("{}/", mounted.collection_route))?;
|
||||
if item_reference.is_empty() || item_reference.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let item = mounted.resolver.resolve(item_reference)?;
|
||||
let projection = WipProjection {
|
||||
route: path.to_string(),
|
||||
capability: mounted.capability.clone(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: item.object,
|
||||
interface: mounted.interface.clone(),
|
||||
descriptor: mounted.descriptor.clone(),
|
||||
interface_validator: mounted.interface_validator.clone(),
|
||||
handler: item.handler,
|
||||
};
|
||||
validate_projection(&projection).ok()?;
|
||||
Some(projection)
|
||||
})
|
||||
}
|
||||
|
||||
fn object_at(&self, path: &str) -> Option<Object> {
|
||||
@@ -1358,6 +1606,7 @@ fn wip_tool_definitions(runtime: Arc<WipRuntime>) -> Vec<ToolDefinition> {
|
||||
|
||||
/// Replace all currently registered ordinary tools with one WIP client surface.
|
||||
/// Call this once after every enabled Feature has contributed its tools.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn install_wip_mode<C: LlmClient, A: Send + Sync>(
|
||||
engine: &mut Engine<C, Mutable, A>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
@@ -1403,12 +1652,16 @@ pub fn install_wip_mode_with_mounts<C: LlmClient, A: Send + Sync>(
|
||||
let (meta, tool) = handle
|
||||
.get_tool(&definition.name)
|
||||
.ok_or_else(|| format!("registered tool `{}` disappeared", definition.name))?;
|
||||
let projection = compatibility_projection(meta, tool, permissions.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
registry
|
||||
.mount(projection)
|
||||
.map_err(|error| error.to_string())?;
|
||||
names.push(definition.name);
|
||||
let capability = format!("tool:{}", meta.name);
|
||||
let name = meta.name.clone();
|
||||
if !registry.replaces_compatibility(&capability) {
|
||||
let projection = compatibility_projection(meta, tool, permissions.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
registry
|
||||
.mount(projection)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
let runtime = Arc::new(WipRuntime::new(
|
||||
WipHost::new(registry),
|
||||
@@ -1632,7 +1885,7 @@ fn decode_native_arguments(
|
||||
.map_err(|error| format!("native WIP arguments do not match the descriptor: {error}"))
|
||||
}
|
||||
|
||||
fn json_to_wip(value: &Json) -> Result<Value, String> {
|
||||
pub(crate) fn json_to_wip(value: &Json) -> Result<Value, String> {
|
||||
match value {
|
||||
Json::Null => Ok(Value::Unit),
|
||||
Json::Bool(value) => Ok(Value::Boolean(*value)),
|
||||
@@ -1670,7 +1923,7 @@ fn json_to_wip(value: &Json) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wip_to_json(value: &Value) -> Result<Json, String> {
|
||||
pub(crate) fn wip_to_json(value: &Value) -> Result<Json, String> {
|
||||
match value {
|
||||
Value::Unit => Ok(Json::Null),
|
||||
Value::Boolean(value) => Ok(Json::Bool(*value)),
|
||||
@@ -2227,6 +2480,162 @@ mod tests {
|
||||
assert_eq!(state.session.security_context().as_str(), "worker-a");
|
||||
}
|
||||
|
||||
struct DynamicResolver {
|
||||
revision: Arc<AtomicUsize>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
struct DynamicHandler {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for DynamicHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
_operation: &str,
|
||||
_arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(WipOperationOutput::native(Value::Unit))
|
||||
}
|
||||
}
|
||||
|
||||
impl WipDynamicItemResolver for DynamicResolver {
|
||||
fn resolve(&self, item_reference: &str) -> Option<WipDynamicItem> {
|
||||
let sequence = item_reference.strip_prefix("O-")?;
|
||||
if sequence.is_empty() || !sequence.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some(WipDynamicItem {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: None,
|
||||
interfaces: vec!["test.dynamic/v1".into()],
|
||||
r#ref: Some(format!("objective:{item_reference}")),
|
||||
validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]),
|
||||
},
|
||||
handler: Arc::new(DynamicHandler {
|
||||
calls: Arc::clone(&self.calls),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_registry(revision: Arc<AtomicUsize>, calls: Arc<AtomicUsize>) -> WipMountRegistry {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature = registry.allocate_feature_route("objective").unwrap();
|
||||
let collection_route = feature.child("objectives").unwrap();
|
||||
let mut collection = compatibility_projection(
|
||||
meta("Dynamic"),
|
||||
Arc::new(EchoTool {
|
||||
calls: Arc::clone(&calls),
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
collection.route = collection_route.clone();
|
||||
collection.capability = "objective:collection".into();
|
||||
collection.kind = WipProjectionKind::Native;
|
||||
collection.object.name = "objectives".into();
|
||||
collection.object.interfaces = vec!["test.dynamic/v1".into()];
|
||||
collection.interface = "test.dynamic/v1".into();
|
||||
collection.handler = Arc::new(DynamicHandler {
|
||||
calls: Arc::clone(&calls),
|
||||
});
|
||||
let descriptor = collection.descriptor.clone();
|
||||
let interface_validator = collection.interface_validator.clone();
|
||||
registry.mount(collection).unwrap();
|
||||
registry
|
||||
.mount_dynamic(WipDynamicMount {
|
||||
collection_route,
|
||||
capability: "objective:item".into(),
|
||||
interface: "test.dynamic/v1".into(),
|
||||
descriptor,
|
||||
interface_validator,
|
||||
resolver: Arc::new(DynamicResolver { revision, calls }),
|
||||
})
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_routes_resolve_only_direct_children_and_reject_stale_calls() {
|
||||
let revision = Arc::new(AtomicUsize::new(1));
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let host = WipHost::new(dynamic_registry(Arc::clone(&revision), Arc::clone(&calls)));
|
||||
let initial = host
|
||||
.projection("/features/objective/objectives/O-3")
|
||||
.unwrap();
|
||||
assert!(
|
||||
host.projection("/features/objective/objectives/O-3/other")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
host.projection("/features/objective/objectives/T-3")
|
||||
.is_none()
|
||||
);
|
||||
assert!(host.projection("/hidden/objectives/O-3").is_none());
|
||||
|
||||
revision.store(2, Ordering::SeqCst);
|
||||
let result = host
|
||||
.call(
|
||||
CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: "/features/objective/objectives/O-3".into(),
|
||||
validator: initial.object.validator,
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: initial.interface,
|
||||
validator: initial.interface_validator,
|
||||
},
|
||||
operation: "call".into(),
|
||||
arguments: BTreeMap::from([(
|
||||
"input".into(),
|
||||
json_to_wip(&json!({"message": "ok"})).unwrap(),
|
||||
)]),
|
||||
},
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker-a".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::ValidatorMismatch,
|
||||
..
|
||||
}))
|
||||
));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_capability_claim_hides_only_claimed_compatibility_route() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls)));
|
||||
engine.register_tool(echo_definition("Other".into(), Arc::clone(&calls)));
|
||||
|
||||
let revision = Arc::new(AtomicUsize::new(1));
|
||||
let mut registry = dynamic_registry(revision, calls);
|
||||
registry
|
||||
.replace_compatibility_tools("/features/objective/objectives", ["Echo"])
|
||||
.unwrap();
|
||||
let runtime =
|
||||
install_wip_mode_with_mounts(&mut engine, None, "worker-a".into(), registry).unwrap();
|
||||
assert!(runtime.host.projection("/tools/Echo").is_none());
|
||||
assert!(runtime.host.projection("/tools/Other").is_some());
|
||||
assert!(
|
||||
runtime
|
||||
.host
|
||||
.projection("/features/objective/objectives")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
struct FailingTool {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user