//! Authorized, validated, transactional linked-primitive construction. #![allow(clippy::missing_errors_doc)] use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture}; use crate::llm::{ToolDefinition, ToolSchema}; use crate::policy::{ AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, FixedCost, Idempotency, OriginClass, PolicyError, PolicyReasonCode, PolicyTool, ResourceCost, ResourceEstimator, Risk, }; use crate::types::{BoundedText, MAX_BODY_BYTES, MAX_OBSERVABLE_DETAIL_BYTES, ToolCallOutcome}; use libremetaverse_types::{UUID, compat::CancellationToken}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::time::Duration; pub const BUILD_DRY_RUN_TOOL: &str = "build_object_dry_run"; pub const BUILD_EXECUTE_TOOL: &str = "build_object_execute"; pub const BUILD_CLEANUP_TOOL: &str = "build_object_cleanup"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct BuildLimits { pub max_prims: usize, pub approval_free_prims: usize, pub max_script_bytes: usize, pub max_dimension_millimeters: u32, pub max_distance_millimeters: u32, pub step_timeout: Duration, pub cleanup_timeout: Duration, pub max_observations: usize, } impl Default for BuildLimits { fn default() -> Self { Self { max_prims: 16, approval_free_prims: 4, max_script_bytes: 32 * 1024, max_dimension_millimeters: 10_000, max_distance_millimeters: 20_000, step_timeout: Duration::from_secs(20), cleanup_timeout: Duration::from_secs(20), max_observations: 256, } } } impl BuildLimits { fn valid(self) -> bool { (1..=256).contains(&self.max_prims) && self.approval_free_prims <= self.max_prims && (256..=64 * 1024).contains(&self.max_script_bytes) && (100..=64_000).contains(&self.max_dimension_millimeters) && (1_000..=256_000).contains(&self.max_distance_millimeters) && !self.step_timeout.is_zero() && self.step_timeout <= Duration::from_mins(2) && !self.cleanup_timeout.is_zero() && self.cleanup_timeout <= Duration::from_mins(2) && (16..=4_096).contains(&self.max_observations) } } #[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum BuildShape { Box, Cylinder, Prism, Sphere, Torus, Tube, Ring, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct BuildPrim { pub id: String, pub parent: Option, pub shape: BuildShape, pub position_millimeters: [i32; 3], pub scale_millimeters: [u32; 3], pub rotation_degrees: [f64; 3], pub color_rgba: [f64; 4], pub material: String, pub texture_inventory_id: Option, pub name: String, pub description: String, pub script: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct BuildPlan { pub version: u32, pub region_id: String, pub prims: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct BuildValidation { pub prim_count: usize, pub link_count: usize, pub script_bytes: usize, pub inventory_operations: usize, pub approval_required: bool, pub estimated_max_seconds: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PrimReceipt { pub plan_id: String, pub object_id: UUID, pub local_id: u32, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct BuildReceipt { pub transaction_id: String, pub root_object_id: String, pub object_ids: Vec, pub orphan_ids: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct BuildCleanupReceipt { pub transaction_id: String, pub deleted_object_ids: Vec, pub orphan_ids: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum BuildObservationKind { Validated, Creating, Created, Configured, Linked, ScriptInserted, Cleaning, Cleaned, Orphaned, Completed, Cancelled, Failed, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct BuildObservation { pub transaction_id: String, pub correlation_id: String, pub kind: BuildObservationKind, pub plan_id: Option, pub object_id: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum BuildError { UnsafeLimits, InvalidPlan, UnsupportedVersion, TooManyPrims, InvalidIdentifier, InvalidGeometry, OutOfRegion, InvalidTopology, InvalidMaterial, InvalidTexture, InvalidScript, LandDenied, Busy, Cancelled, TimedOut, GridOperation, UnknownTransaction, CleanupIncomplete, } impl fmt::Display for BuildError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::UnsafeLimits => "unsafe build limits", Self::InvalidPlan => "invalid build plan", Self::UnsupportedVersion => "unsupported build plan version", Self::TooManyPrims => "build exceeds primitive limit", Self::InvalidIdentifier => "invalid build identifier or text", Self::InvalidGeometry => "invalid or unbounded geometry", Self::OutOfRegion => "build is outside the current region or distance limit", Self::InvalidTopology => "build link topology is invalid", Self::InvalidMaterial => "unsupported material", Self::InvalidTexture => "texture is not an owned inventory UUID", Self::InvalidScript => "script failed local LSL validation", Self::LandDenied => "agent lacks build rights at the requested location", Self::Busy => "another build transaction is active", Self::Cancelled => "build transaction was cancelled", Self::TimedOut => "build step timed out", Self::GridOperation => "native grid operation failed", Self::UnknownTransaction => "unknown completed build transaction", Self::CleanupIncomplete => { "cleanup was incomplete; recoverable orphan IDs were recorded" } }) } } impl std::error::Error for BuildError {} pub type BuildFuture<'a, T> = Pin> + Send + 'a>>; pub trait BuildGrid: Send + Sync + 'static { fn validate_land( &self, region_id: &str, positions: &[[i32; 3]], cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; fn texture_is_owned( &self, texture: UUID, cancellation: CancellationToken, ) -> BuildFuture<'_, bool>; fn create_prim( &self, transaction_id: &str, prim: &BuildPrim, cancellation: CancellationToken, ) -> BuildFuture<'_, PrimReceipt>; fn configure_prim( &self, receipt: &PrimReceipt, prim: &BuildPrim, cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; fn link_prims( &self, parent: &PrimReceipt, child: &PrimReceipt, cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; fn insert_script( &self, receipt: &PrimReceipt, source: &str, cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; fn confirm_prim( &self, receipt: &PrimReceipt, cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; fn delete_owned_prim( &self, receipt: &PrimReceipt, cancellation: CancellationToken, ) -> BuildFuture<'_, ()>; } /// Type-erased cancellation hook used by the operator control plane. pub trait BuildControl: Send + Sync { fn cancel_build(&self, transaction_id: &str) -> bool; fn active_build(&self) -> Option; fn build_orphans(&self) -> Vec; fn build_progress(&self) -> Option; } struct State { active: Option, cancelled: BTreeSet, observations: VecDeque, orphans: BTreeMap>, completed: BTreeMap>, } pub struct BuildService { grid: Arc, limits: BuildLimits, state: Mutex, } impl BuildService { pub fn new(grid: Arc, limits: BuildLimits) -> Result { if !limits.valid() { return Err(BuildError::UnsafeLimits); } Ok(Self { grid, limits, state: Mutex::new(State { active: None, cancelled: BTreeSet::new(), observations: VecDeque::new(), orphans: BTreeMap::new(), completed: BTreeMap::new(), }), }) } #[must_use] pub fn observations(&self) -> Vec { lock(&self.state).observations.iter().cloned().collect() } #[must_use] pub fn orphan_ids(&self, transaction: &str) -> Vec { lock(&self.state) .orphans .get(transaction) .map_or_else(Vec::new, |v| v.iter().map(|r| r.object_id).collect()) } pub fn cancel(&self, transaction: &str) -> bool { let mut state = lock(&self.state); if state.active.as_deref() == Some(transaction) { state.cancelled.insert(transaction.to_owned()); true } else { false } } fn observe( &self, transaction: &str, correlation: &str, kind: BuildObservationKind, plan: Option<&str>, object: Option, ) { let mut state = lock(&self.state); if state.observations.len() == self.limits.max_observations { state.observations.pop_front(); } state.observations.push_back(BuildObservation { transaction_id: transaction.to_owned(), correlation_id: correlation.to_owned(), kind, plan_id: plan.map(str::to_owned), object_id: object.map(|id| id.to_string()), }); } fn cancelled(&self, transaction: &str, token: &CancellationToken) -> bool { token.is_cancellation_requested() || lock(&self.state).cancelled.contains(transaction) } pub fn validate(&self, plan: &BuildPlan) -> Result { validate_plan(plan, self.limits) } pub async fn dry_run( &self, plan: &BuildPlan, cancellation: CancellationToken, ) -> Result { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let validation = self.validate(plan)?; timed( self.limits.step_timeout, self.grid.validate_land( &plan.region_id, &plan .prims .iter() .map(|p| p.position_millimeters) .collect::>(), cancellation.clone(), ), ) .await?; for texture in plan .prims .iter() .filter_map(|p| p.texture_inventory_id.as_deref()) { let id = UUID::new_with_string(texture.to_owned()) .map_err(|_| BuildError::InvalidTexture)?; if !timed( self.limits.step_timeout, self.grid.texture_is_owned(id, cancellation.clone()), ) .await? { return Err(BuildError::InvalidTexture); } } Ok(validation) } pub async fn execute( &self, transaction: &str, correlation: &str, plan: BuildPlan, cancellation: CancellationToken, ) -> Result { if transaction.is_empty() || transaction.len() > 128 { return Err(BuildError::InvalidIdentifier); } { let mut state = lock(&self.state); if state.active.is_some() || state.completed.len() >= self.limits.max_observations { return Err(BuildError::Busy); } state.active = Some(transaction.to_owned()); state.cancelled.remove(transaction); state.orphans.remove(transaction); } let result = self .execute_inner(transaction, correlation, &plan, cancellation.clone()) .await; let mut state = lock(&self.state); state.active = None; state.cancelled.remove(transaction); result } #[allow(clippy::too_many_lines)] async fn execute_inner( &self, transaction: &str, correlation: &str, plan: &BuildPlan, cancellation: CancellationToken, ) -> Result { self.dry_run(plan, cancellation.clone()).await?; self.observe( transaction, correlation, BuildObservationKind::Validated, None, None, ); let mut made = Vec::new(); let mut by_id = BTreeMap::new(); let mutation = async { for prim in &plan.prims { if self.cancelled(transaction, &cancellation) { return Err(BuildError::Cancelled); } self.observe( transaction, correlation, BuildObservationKind::Creating, Some(&prim.id), None, ); let receipt = timed( self.limits.step_timeout, self.grid .create_prim(transaction, prim, cancellation.clone()), ) .await?; made.push(receipt.clone()); by_id.insert(prim.id.clone(), receipt.clone()); self.observe( transaction, correlation, BuildObservationKind::Created, Some(&prim.id), Some(receipt.object_id), ); timed( self.limits.step_timeout, self.grid.confirm_prim(&receipt, cancellation.clone()), ) .await?; timed( self.limits.step_timeout, self.grid .configure_prim(&receipt, prim, cancellation.clone()), ) .await?; timed( self.limits.step_timeout, self.grid.confirm_prim(&receipt, cancellation.clone()), ) .await?; self.observe( transaction, correlation, BuildObservationKind::Configured, Some(&prim.id), Some(receipt.object_id), ); if let Some(source) = &prim.script { timed( self.limits.step_timeout, self.grid .insert_script(&receipt, source, cancellation.clone()), ) .await?; timed( self.limits.step_timeout, self.grid.confirm_prim(&receipt, cancellation.clone()), ) .await?; self.observe( transaction, correlation, BuildObservationKind::ScriptInserted, Some(&prim.id), Some(receipt.object_id), ); } } for prim in &plan.prims { if let Some(parent_id) = &prim.parent { let parent = by_id.get(parent_id).ok_or(BuildError::InvalidTopology)?; let child = by_id.get(&prim.id).ok_or(BuildError::InvalidTopology)?; timed( self.limits.step_timeout, self.grid.link_prims(parent, child, cancellation.clone()), ) .await?; timed( self.limits.step_timeout, self.grid.confirm_prim(child, cancellation.clone()), ) .await?; self.observe( transaction, correlation, BuildObservationKind::Linked, Some(&prim.id), Some(child.object_id), ); } } Ok::<(), BuildError>(()) } .await; if let Err(error) = mutation { let _ = self.cleanup(transaction, correlation, &made).await; self.observe( transaction, correlation, if error == BuildError::Cancelled { BuildObservationKind::Cancelled } else { BuildObservationKind::Failed }, None, None, ); return Err(if self.orphan_ids(transaction).is_empty() { error } else { BuildError::CleanupIncomplete }); } let root = plan .prims .iter() .find(|p| p.parent.is_none()) .and_then(|p| by_id.get(&p.id)) .ok_or(BuildError::InvalidTopology)?; self.observe( transaction, correlation, BuildObservationKind::Completed, None, Some(root.object_id), ); lock(&self.state) .completed .insert(transaction.to_owned(), made.clone()); Ok(BuildReceipt { transaction_id: transaction.to_owned(), root_object_id: root.object_id.to_string(), object_ids: made.iter().map(|r| r.object_id.to_string()).collect(), orphan_ids: Vec::new(), }) } pub async fn cleanup_completed( &self, transaction: &str, correlation: &str, cancellation: CancellationToken, ) -> Result { if transaction.is_empty() || transaction.len() > 128 { return Err(BuildError::InvalidIdentifier); } if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let made = { let mut state = lock(&self.state); if state.active.is_some() { return Err(BuildError::Busy); } let made = state .completed .get(transaction) .cloned() .ok_or(BuildError::UnknownTransaction)?; state.active = Some(transaction.to_owned()); made }; let orphaned = self.cleanup(transaction, correlation, &made).await; let orphan_ids = orphaned .iter() .map(|receipt| receipt.object_id.to_string()) .collect::>(); let orphan_set = orphaned .iter() .map(|receipt| receipt.object_id) .collect::>(); let deleted_object_ids = made .iter() .filter(|receipt| !orphan_set.contains(&receipt.object_id)) .map(|receipt| receipt.object_id.to_string()) .collect(); let mut state = lock(&self.state); state.active = None; state.cancelled.remove(transaction); if orphaned.is_empty() { state.completed.remove(transaction); } else { state.completed.insert(transaction.to_owned(), orphaned); } Ok(BuildCleanupReceipt { transaction_id: transaction.to_owned(), deleted_object_ids, orphan_ids, }) } async fn cleanup( &self, transaction: &str, correlation: &str, made: &[PrimReceipt], ) -> Vec { self.observe( transaction, correlation, BuildObservationKind::Cleaning, None, None, ); let mut orphaned = Vec::new(); for receipt in made.iter().rev() { if timed( self.limits.cleanup_timeout, self.grid .delete_owned_prim(receipt, CancellationToken::default()), ) .await .is_ok() { self.observe( transaction, correlation, BuildObservationKind::Cleaned, Some(&receipt.plan_id), Some(receipt.object_id), ); } else { orphaned.push(receipt.clone()); self.observe( transaction, correlation, BuildObservationKind::Orphaned, Some(&receipt.plan_id), Some(receipt.object_id), ); } } let mut state = lock(&self.state); if orphaned.is_empty() { state.orphans.remove(transaction); } else { state .orphans .insert(transaction.to_owned(), orphaned.clone()); } orphaned } } impl BuildControl for BuildService { fn cancel_build(&self, transaction_id: &str) -> bool { self.cancel(transaction_id) } fn active_build(&self) -> Option { lock(&self.state).active.clone() } fn build_orphans(&self) -> Vec { lock(&self.state) .orphans .values() .flatten() .map(|receipt| receipt.object_id.to_string()) .collect() } fn build_progress(&self) -> Option { lock(&self.state) .observations .back() .map(|observation| format!("{:?}", observation.kind).to_ascii_lowercase()) } } async fn timed(duration: Duration, future: BuildFuture<'_, T>) -> Result { tokio::time::timeout(duration, future) .await .map_err(|_| BuildError::TimedOut)? } fn lock(value: &Mutex) -> std::sync::MutexGuard<'_, T> { value .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } #[allow(clippy::too_many_lines)] fn validate_plan(plan: &BuildPlan, limits: BuildLimits) -> Result { if plan.version != 1 { return Err(BuildError::UnsupportedVersion); } if plan.region_id.is_empty() || plan.region_id.len() > 128 || plan.prims.is_empty() { return Err(BuildError::InvalidPlan); } if plan.prims.len() > limits.max_prims { return Err(BuildError::TooManyPrims); } let mut ids = BTreeSet::new(); let mut script_bytes = 0usize; let mut inventory = 0usize; for prim in &plan.prims { if prim.id.is_empty() || prim.id.len() > 64 || !prim .id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') || !ids.insert(prim.id.clone()) || prim.name.is_empty() || prim.name.len() > 63 || prim.description.len() > 127 { return Err(BuildError::InvalidIdentifier); } if prim .scale_millimeters .iter() .any(|v| *v == 0 || *v > limits.max_dimension_millimeters) || prim .position_millimeters .iter() .any(|v| v.unsigned_abs() > limits.max_distance_millimeters) || prim .rotation_degrees .iter() .any(|v| !v.is_finite() || v.abs() > 360.0) || prim .color_rgba .iter() .any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) { return Err(BuildError::InvalidGeometry); } if !matches!( prim.material.as_str(), "wood" | "metal" | "glass" | "stone" | "plastic" | "rubber" | "flesh" ) { return Err(BuildError::InvalidMaterial); } if let Some(texture) = &prim.texture_inventory_id { UUID::new_with_string(texture.clone()).map_err(|_| BuildError::InvalidTexture)?; inventory += 1; } if let Some(script) = &prim.script { let candidate = crate::script_delivery::GeneratedScript { name: prim.name.clone(), description: if prim.description.is_empty() { "Object script".to_owned() } else { prim.description.clone() }, source: script.clone(), }; if crate::script_delivery::validate_script(&candidate, limits.max_script_bytes).is_err() { return Err(BuildError::InvalidScript); } script_bytes += script.len(); inventory += 1; } } let roots = plan.prims.iter().filter(|p| p.parent.is_none()).count(); if roots != 1 { return Err(BuildError::InvalidTopology); } for prim in &plan.prims { if let Some(parent) = &prim.parent { if parent == &prim.id || !ids.contains(parent) { return Err(BuildError::InvalidTopology); } let mut cursor = Some(parent.as_str()); let mut seen = BTreeSet::new(); while let Some(id) = cursor { if !seen.insert(id) { return Err(BuildError::InvalidTopology); } cursor = plan .prims .iter() .find(|p| p.id == id) .and_then(|p| p.parent.as_deref()); } } } Ok(BuildValidation { prim_count: plan.prims.len(), link_count: plan.prims.len() - 1, script_bytes, inventory_operations: inventory, approval_required: plan.prims.len() > limits.approval_free_prims, estimated_max_seconds: u64::try_from(plan.prims.len()) .unwrap_or(u64::MAX) .saturating_mul(6) .saturating_sub(1) .saturating_mul(limits.step_timeout.as_secs().max(1)), }) } #[derive(Clone)] struct BuildEstimator { limits: BuildLimits, } impl ResourceEstimator for BuildEstimator { fn estimate(&self, arguments: &Value) -> Result { let plan: BuildPlan = serde_json::from_value( arguments .get("plan") .cloned() .ok_or(PolicyReasonCode::InvalidArguments)?, ) .map_err(|_| PolicyReasonCode::InvalidArguments)?; let valid = validate_plan(&plan, self.limits).map_err(|_| PolicyReasonCode::InvalidArguments)?; Ok(ResourceCost { tool_calls: 1, inventory_operations: valid.inventory_operations as u64, build_prims: valid.prim_count as u64, ..ResourceCost::default() }) } } #[allow(clippy::too_many_lines)] pub fn build_policy_tools(limits: BuildLimits) -> Result, PolicyError> { if !limits.valid() { return Err(PolicyError::InvalidRegistration); } let vector = |length| ToolSchema::Array { items: Box::new(ToolSchema::Number), max_items: length, }; let prim_properties = BTreeMap::from([ ("id".into(), ToolSchema::String), ( "parent".into(), ToolSchema::Nullable(Box::new(ToolSchema::String)), ), ("shape".into(), ToolSchema::String), ("position_millimeters".into(), vector(3)), ("scale_millimeters".into(), vector(3)), ("rotation_degrees".into(), vector(3)), ("color_rgba".into(), vector(4)), ("material".into(), ToolSchema::String), ( "texture_inventory_id".into(), ToolSchema::Nullable(Box::new(ToolSchema::String)), ), ("name".into(), ToolSchema::String), ("description".into(), ToolSchema::String), ( "script".into(), ToolSchema::Nullable(Box::new(ToolSchema::String)), ), ]); let prim_required = BTreeSet::from([ "id".into(), "shape".into(), "position_millimeters".into(), "scale_millimeters".into(), "rotation_degrees".into(), "color_rgba".into(), "material".into(), "name".into(), "description".into(), ]); let plan_schema = ToolSchema::Object { properties: BTreeMap::from([ ("version".into(), ToolSchema::Integer), ("region_id".into(), ToolSchema::String), ( "prims".into(), ToolSchema::Array { items: Box::new(ToolSchema::Object { properties: prim_properties, required: prim_required, additional_properties: false, }), max_items: limits.max_prims, }, ), ]), required: BTreeSet::from(["version".into(), "region_id".into(), "prims".into()]), additional_properties: false, }; let plan = ToolSchema::Object { properties: BTreeMap::from([("plan".into(), plan_schema)]), required: BTreeSet::from(["plan".into()]), additional_properties: false, }; let cleanup = ToolSchema::Object { properties: BTreeMap::from([("transaction_id".into(), ToolSchema::String)]), required: BTreeSet::from(["transaction_id".into()]), additional_properties: false, }; let origins = || AllowedOrigins::new([OriginClass::AuthorizedIm, OriginClass::LocalOperator]); let max = ResourceCost { tool_calls: 1, inventory_operations: (limits.max_prims * 2) as u64, build_prims: limits.max_prims as u64, ..ResourceCost::default() }; let threshold = ResourceCost { tool_calls: 1, inventory_operations: max.inventory_operations, build_prims: limits.approval_free_prims as u64, ..ResourceCost::default() }; Ok(vec![ PolicyTool::new( ToolDefinition { name: BoundedText::new("tool.name", BUILD_DRY_RUN_TOOL)?, description: BoundedText::new( "tool.description", "Validate a versioned bounded linked-primitive build and return its exact resource estimate without mutation", )?, schema: plan.clone(), mutating: false, }, Capability::Informational, Risk::ReadOnly, origins()?, max, Idempotency::Idempotent, ApprovalRule::Never, false, Arc::new(BuildEstimator { limits }), )?, PolicyTool::new( ToolDefinition { name: BoundedText::new("tool.name", BUILD_EXECUTE_TOOL)?, description: BoundedText::new( "tool.description", "Execute one validated transactional linked-primitive build; larger plans require operator approval", )?, schema: plan, mutating: true, }, Capability::Build, Risk::Build, origins()?, max, Idempotency::NonIdempotent, ApprovalRule::WhenExceeds(threshold), false, Arc::new(BuildEstimator { limits }), )?, PolicyTool::new( ToolDefinition { name: BoundedText::new("tool.name", BUILD_CLEANUP_TOOL)?, description: BoundedText::new( "tool.description", "Delete only objects retained from one completed build transaction and report any recoverable failures", )?, schema: cleanup, mutating: true, }, Capability::Build, Risk::Build, origins()?, ResourceCost::one_call(), Idempotency::NonIdempotent, ApprovalRule::Never, false, Arc::new(FixedCost(ResourceCost::one_call())), )?, ]) } pub struct BuildToolBackend { service: Arc>, } impl BuildToolBackend { #[must_use] pub fn new(service: Arc>) -> Self { Self { service } } } impl AuthorizedToolBackend for BuildToolBackend { fn apply( &self, action: AuthorizedAction, cancellation: CancellationToken, ) -> BackendFuture<'_, Result> { Box::pin(async move { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct BuildArgs { plan: BuildPlan, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CleanupArgs { transaction_id: String, } let call_id = action.call().call_id.clone(); let transaction = format!("build-{}", action.authorization_id()); let result = match action.call().name.as_str() { BUILD_DRY_RUN_TOOL => { match serde_json::from_str::(action.call().arguments_json.as_str()) { Ok(args) => self .service .dry_run(&args.plan, cancellation) .await .and_then(|value| { serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan) }), Err(_) => Err(BuildError::InvalidPlan), } } BUILD_EXECUTE_TOOL => { match serde_json::from_str::(action.call().arguments_json.as_str()) { Ok(args) => self .service .execute(&transaction, call_id.as_str(), args.plan, cancellation) .await .and_then(|value| { serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan) }), Err(_) => Err(BuildError::InvalidPlan), } } BUILD_CLEANUP_TOOL => { match serde_json::from_str::(action.call().arguments_json.as_str()) { Ok(args) => self .service .cleanup_completed(&args.transaction_id, call_id.as_str(), cancellation) .await .and_then(|value| { serde_json::to_string(&value).map_err(|_| BuildError::InvalidPlan) }), Err(_) => Err(BuildError::InvalidPlan), } } _ => Err(BuildError::InvalidPlan), }; Ok(match result { Ok(value) => ToolCallOutcome::Completed { call_id, result: BoundedText::::new("build.result", value).map_err( |_| BackendError::Operation { operation: "bounded build result", }, )?, }, Err(error) => { let orphans = self.service.orphan_ids(&transaction); let reason = if orphans.is_empty() { error.to_string() } else { format!( "{error}; orphan_ids={}", orphans .iter() .map(ToString::to_string) .collect::>() .join(",") ) }; ToolCallOutcome::Rejected { call_id, reason: BoundedText::::new( "build.rejection", reason, ) .map_err(|_| BackendError::Operation { operation: "bounded build rejection", })?, } } }) }) } } /// Live adapter over the original compatibility surface. All orchestration, /// policy, validation, correlation, and cleanup ownership remain in `MetaCrate`. #[cfg(feature = "live-grid")] pub struct LibremetaverseBuildGrid { client: libremetaverse::GridClient, agent: Arc, objects: libremetaverse::ObjectManager, inventory: libremetaverse::InventoryManager, resolved_textures: Mutex>, } #[cfg(feature = "live-grid")] #[allow(clippy::cast_precision_loss)] impl LibremetaverseBuildGrid { #[must_use] pub fn new(owner: &crate::backend::LibremetaverseClientOwner) -> Self { Self { client: owner.client().clone(), agent: owner.agent(), objects: owner.client().objects(), inventory: owner.client().inventory(), resolved_textures: Mutex::new(BTreeMap::new()), } } fn simulator(&self) -> Result { self.client .network() .current_sim() .ok_or(BuildError::GridOperation) } fn world_position(&self, prim: &BuildPrim) -> libremetaverse_types::Vector3 { let base = self.agent.sim_position(); libremetaverse_types::Vector3 { x: base.x + prim.position_millimeters[0] as f32 / 1000.0, y: base.y + prim.position_millimeters[1] as f32 / 1000.0, z: base.z + prim.position_millimeters[2] as f32 / 1000.0, } } } #[cfg(feature = "live-grid")] #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] impl BuildGrid for LibremetaverseBuildGrid { fn validate_land( &self, region_id: &str, positions: &[[i32; 3]], cancellation: CancellationToken, ) -> BuildFuture<'_, ()> { let region = region_id.to_owned(); let positions = positions.to_vec(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let simulator = self.simulator()?; if simulator.region_id.to_string() != region { return Err(BuildError::OutOfRegion); } let base = self.agent.sim_position(); let reported_bounds_contain_agent = base.x <= simulator.size_x as f32 && base.y <= simulator.size_y as f32; for offset in positions { let point = libremetaverse_types::Vector3 { x: base.x + offset[0] as f32 / 1000.0, y: base.y + offset[1] as f32 / 1000.0, z: base.z + offset[2] as f32 / 1000.0, }; if point.x < 0.0 || point.y < 0.0 || (reported_bounds_contain_agent && (point.x > simulator.size_x as f32 || point.y > simulator.size_y as f32)) { return Err(BuildError::OutOfRegion); } let parcels = simulator .parcels .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let parcel = parcels .values() .filter(|parcel| { point.x >= parcel.aabb_min.x && point.x <= parcel.aabb_max.x && point.y >= parcel.aabb_min.y && point.y <= parcel.aabb_max.y }) .min_by_key(|parcel| parcel.local_id); // Some OpenSim varregions expose only the legacy 256 m parcel // cache; the server remains authoritative for the bounded rez. let allowed = parcel.map_or(!reported_bounds_contain_agent, |parcel| { parcel.owner_id == self.agent.agent_id() || parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_OBJECTS.0 != 0 || (parcel.group_id != UUID::zero() && parcel.group_id == self.agent.active_group() && parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_GROUP_OBJECTS.0 != 0) }); if !allowed { return Err(BuildError::LandDenied); } } Ok(()) }) } fn texture_is_owned( &self, texture: UUID, cancellation: CancellationToken, ) -> BuildFuture<'_, bool> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let store = self.inventory.store().ok_or(BuildError::InvalidTexture)?; let node = store .get_node_for(texture) .map_err(|_| BuildError::InvalidTexture)?; let object = node.data().ok_or(BuildError::InvalidTexture)?; let item = object.inventory_item().ok_or(BuildError::InvalidTexture)?; if item.asset_type() != libremetaverse_types::AssetType::Texture || object.inventory_base().owner_id() != self.agent.agent_id() { return Ok(false); } lock(&self.resolved_textures).insert(texture, item.asset_uuid()); Ok(true) }) } fn create_prim( &self, _: &str, prim: &BuildPrim, cancellation: CancellationToken, ) -> BuildFuture<'_, PrimReceipt> { let prim = prim.clone(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let simulator = self.simulator()?; let target = self.world_position(&prim); let z_tolerance = prim.scale_millimeters[2] as f32 / 2_000.0 + 0.1; let (sender, receiver) = tokio::sync::oneshot::channel(); let sender = Arc::new(Mutex::new(Some(sender))); let callback_sender = Arc::clone(&sender); let _subscription = self.objects.subscribe_object_update(Arc::new(move |event| { let found = event.prim(); if event.is_new() && (found.position.x - target.x).abs() < 0.05 && (found.position.y - target.y).abs() < 0.05 && (found.position.z - target.z).abs() <= z_tolerance && let Some(sender) = lock(&callback_sender).take() { let _ = sender.send(PrimReceipt { plan_id: prim.id.clone(), object_id: found.id, local_id: found.local_id, }); } })); let shape = match prim.shape { BuildShape::Box => libremetaverse_types::PrimType::Box, BuildShape::Cylinder => libremetaverse_types::PrimType::Cylinder, BuildShape::Prism => libremetaverse_types::PrimType::Prism, BuildShape::Sphere => libremetaverse_types::PrimType::Sphere, BuildShape::Torus => libremetaverse_types::PrimType::Torus, BuildShape::Tube => libremetaverse_types::PrimType::Tube, BuildShape::Ring => libremetaverse_types::PrimType::Ring, }; let construction = libremetaverse::ObjectManager::build_basic_shape(shape) .map_err(|_| BuildError::GridOperation)?; let scale = libremetaverse_types::Vector3 { x: prim.scale_millimeters[0] as f32 / 1000.0, y: prim.scale_millimeters[1] as f32 / 1000.0, z: prim.scale_millimeters[2] as f32 / 1000.0, }; let rotation = libremetaverse_types::Quaternion::create_from_eulers_with_single_single_single( prim.rotation_degrees[0].to_radians() as f32, prim.rotation_degrees[1].to_radians() as f32, prim.rotation_degrees[2].to_radians() as f32, ) .map_err(|_| BuildError::InvalidGeometry)?; self.objects .add_prim_with_simulator_construction_data_uuid_vector3_vector3_quaternion( simulator, construction, self.agent.active_group(), target, scale, rotation, ) .map_err(|_| BuildError::GridOperation)?; receiver.await.map_err(|_| BuildError::GridOperation) }) } fn configure_prim( &self, receipt: &PrimReceipt, prim: &BuildPrim, cancellation: CancellationToken, ) -> BuildFuture<'_, ()> { let receipt = receipt.clone(); let prim = prim.clone(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let simulator = self.simulator()?; self.objects .set_name(simulator.clone(), receipt.local_id, prim.name.clone()) .map_err(|_| BuildError::GridOperation)?; self.objects .set_description( simulator.clone(), receipt.local_id, prim.description.clone(), ) .map_err(|_| BuildError::GridOperation)?; let material = match prim.material.as_str() { "wood" => libremetaverse_types::Material::Wood, "metal" => libremetaverse_types::Material::Metal, "glass" => libremetaverse_types::Material::Glass, "stone" => libremetaverse_types::Material::Stone, "plastic" => libremetaverse_types::Material::Plastic, "rubber" => libremetaverse_types::Material::Rubber, "flesh" => libremetaverse_types::Material::Flesh, _ => return Err(BuildError::InvalidMaterial), }; self.objects .set_material(simulator.clone(), receipt.local_id, material) .map_err(|_| BuildError::GridOperation)?; let texture = prim .texture_inventory_id .as_ref() .and_then(|id| UUID::new_with_string(id.clone()).ok()) .and_then(|id| lock(&self.resolved_textures).get(&id).copied()) .unwrap_or_else(libremetaverse::PrimitiveTextureEntry::white_texture); let mut entry = libremetaverse::PrimitiveTextureEntry::new_with_uuid(texture) .map_err(|_| BuildError::GridOperation)?; if let Some(face) = entry.default_texture.as_mut() { face.set_rgba(libremetaverse_types::Color4 { r: prim.color_rgba[0] as f32, g: prim.color_rgba[1] as f32, b: prim.color_rgba[2] as f32, a: prim.color_rgba[3] as f32, }); } self.objects .set_textures_with_simulator_u_int32_texture_entry( simulator.clone(), receipt.local_id, entry, ) .map_err(|_| BuildError::GridOperation)?; for who in [ libremetaverse::PermissionWho::EVERYONE, libremetaverse::PermissionWho::GROUP, libremetaverse::PermissionWho::NEXT_OWNER, ] { self.objects .set_permissions( simulator.clone(), vec![receipt.local_id], who, libremetaverse::PermissionMask::ALL, false, ) .map_err(|_| BuildError::GridOperation)?; } Ok(()) }) } fn link_prims( &self, parent: &PrimReceipt, child: &PrimReceipt, cancellation: CancellationToken, ) -> BuildFuture<'_, ()> { let parent = parent.clone(); let child = child.clone(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } self.objects .link_prims(self.simulator()?, vec![parent.local_id, child.local_id]) .map_err(|_| BuildError::GridOperation) }) } fn insert_script( &self, receipt: &PrimReceipt, source: &str, cancellation: CancellationToken, ) -> BuildFuture<'_, ()> { let receipt = receipt.clone(); let source = source.as_bytes().to_vec(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let item_id = UUID::random().map_err(|_| BuildError::GridOperation)?; let mut item = libremetaverse::InventoryItem::new_with_inventory_type_uuid( libremetaverse_types::InventoryType::LSL, item_id, ) .map_err(|_| BuildError::GridOperation)?; item.set_description("Validated MetaCrate object script".into()); item.set_asset_type(libremetaverse_types::AssetType::Script); item.set_permissions(libremetaverse::Permissions::full_permissions()); let task_id = self .inventory .update_task_inventory(receipt.local_id, item, Some(self.simulator()?), Some(false)) .map_err(|_| BuildError::GridOperation)?; let result = self .inventory .request_update_script_task( source, item_id, receipt.object_id, true, true, Some(cancellation), None, ) .await .map_err(|_| BuildError::GridOperation)?; if !result.0 || task_id == UUID::zero() { return Err(BuildError::GridOperation); } Ok(()) }) } fn confirm_prim( &self, receipt: &PrimReceipt, cancellation: CancellationToken, ) -> BuildFuture<'_, ()> { let receipt = receipt.clone(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(BuildError::Cancelled); } let primitive = self .objects .get_primitive( self.simulator()?, receipt.local_id, receipt.object_id, false, ) .map_err(|_| BuildError::GridOperation)?; if primitive.id != receipt.object_id { return Err(BuildError::GridOperation); } Ok(()) }) } fn delete_owned_prim( &self, receipt: &PrimReceipt, _: CancellationToken, ) -> BuildFuture<'_, ()> { let receipt = receipt.clone(); Box::pin(async move { self.inventory .request_de_rez_to_inventory_with_u_int32_de_rez_destination_uuid_uuid( receipt.local_id, libremetaverse::DeRezDestination::TrashFolder, UUID::zero(), UUID::random().map_err(|_| BuildError::GridOperation)?, ) .map_err(|_| BuildError::GridOperation) }) } }