From 64edd8df4628cd19ccf2d5d1fe0cfccc0e8c59d0 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 18 Aug 2026 11:32:32 +0200 Subject: [PATCH] Implement transactional scripted prim builds (#131) --- crates/metacrate-grid-agent/src/build.rs | 1323 +++++++++++++++++ .../metacrate-grid-agent/src/build_tests.rs | 363 +++++ .../metacrate-grid-agent/src/control_plane.rs | 3 + .../src/control_plane_tests.rs | 3 + .../src/control_runtime.rs | 18 +- crates/metacrate-grid-agent/src/lib.rs | 10 + crates/metacrate-grid-agent/src/main.rs | 47 +- crates/metacrate-grid-agent/src/tui.rs | 8 + crates/metacrate-grid-agent/src/tui_tests.rs | 3 + .../tests/dependency_policy.rs | 4 +- crates/metacrate/Cargo.toml | 4 + 11 files changed, 1766 insertions(+), 20 deletions(-) create mode 100644 crates/metacrate-grid-agent/src/build.rs create mode 100644 crates/metacrate-grid-agent/src/build_tests.rs diff --git a/crates/metacrate-grid-agent/src/build.rs b/crates/metacrate-grid-agent/src/build.rs new file mode 100644 index 0000000..7ba904d --- /dev/null +++ b/crates/metacrate-grid-agent/src/build.rs @@ -0,0 +1,1323 @@ +//! 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, 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"; + +#[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, 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, + 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::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>, +} +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(), + }), + }) + } + #[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() { + 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 { + 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), + ); + 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(), + }) + } + async fn cleanup(&self, transaction: &str, correlation: &str, made: &[PrimReceipt]) { + 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), + ); + } + } + if !orphaned.is_empty() { + lock(&self.state) + .orphans + .insert(transaction.to_owned(), 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::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::String), + ("name".into(), ToolSchema::String), + ("description".into(), ToolSchema::String), + ("script".into(), 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 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 }), + )?, + ]) +} + +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 Args { + plan: BuildPlan, + } + let call_id = action.call().call_id.clone(); + let transaction = format!("build-{}", action.authorization_id()); + let result = serde_json::from_str::(action.call().arguments_json.as_str()) + .map_err(|_| BuildError::InvalidPlan); + let result = match result { + Ok(args) if action.call().name.as_str() == BUILD_DRY_RUN_TOOL => self + .service + .dry_run(&args.plan, cancellation) + .await + .and_then(|v| serde_json::to_string(&v).map_err(|_| BuildError::InvalidPlan)), + Ok(args) if action.call().name.as_str() == BUILD_EXECUTE_TOOL => self + .service + .execute(&transaction, call_id.as_str(), args.plan, cancellation) + .await + .and_then(|v| serde_json::to_string(&v).map_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 parcels = self.client.parcels(); + let base = self.agent.sim_position(); + 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 !(0.0..=256.0).contains(&point.x) || !(0.0..=256.0).contains(&point.y) { + return Err(BuildError::OutOfRegion); + } + let local = parcels + .get_parcel_local_id(simulator.clone(), point) + .map_err(|_| BuildError::LandDenied)?; + if local == 0 { + return Err(BuildError::LandDenied); + } + let allowed = simulator + .parcels + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&local) + .is_some_and(|parcel| { + parcel.owner_id == self.agent.agent_id() + || parcel.flags.0 & libremetaverse::ParcelFlags::CREATE_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 (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() < 0.05 + && 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, + UUID::zero(), + 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) + }) + } +} diff --git a/crates/metacrate-grid-agent/src/build_tests.rs b/crates/metacrate-grid-agent/src/build_tests.rs new file mode 100644 index 0000000..5ca949d --- /dev/null +++ b/crates/metacrate-grid-agent/src/build_tests.rs @@ -0,0 +1,363 @@ +use crate::build::*; +use crate::{ + ActionOrigin, MemoryPolicyAudit, PolicyAuditSink, PolicyGateway, PolicyLimits, + PolicyRequestContext, +}; +use libremetaverse_types::{ + UUID, + compat::{CancellationToken, CancellationTokenSource}, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct FakeGrid { + calls: Mutex>, + fail: Mutex>, + deny_land: Mutex, + owned: Mutex, + next: Mutex, + hang_create: Mutex, +} +impl FakeGrid { + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + fn record(&self, value: &str) -> Result<(), BuildError> { + self.calls.lock().unwrap().push(value.to_owned()); + if self + .fail + .lock() + .unwrap() + .iter() + .any(|failed| failed == value) + { + Err(BuildError::GridOperation) + } else { + Ok(()) + } + } +} +impl BuildGrid for FakeGrid { + fn validate_land(&self, _: &str, _: &[[i32; 3]], _: CancellationToken) -> BuildFuture<'_, ()> { + Box::pin(async move { + self.record("land")?; + if *self.deny_land.lock().unwrap() { + Err(BuildError::LandDenied) + } else { + Ok(()) + } + }) + } + fn texture_is_owned(&self, _: UUID, _: CancellationToken) -> BuildFuture<'_, bool> { + Box::pin(async move { + self.record("texture")?; + Ok(*self.owned.lock().unwrap()) + }) + } + fn create_prim( + &self, + _: &str, + prim: &BuildPrim, + _: CancellationToken, + ) -> BuildFuture<'_, PrimReceipt> { + let id = prim.id.clone(); + Box::pin(async move { + self.record(&format!("create:{id}"))?; + if *self.hang_create.lock().unwrap() { + std::future::pending::<()>().await; + } + let mut next = self.next.lock().unwrap(); + *next += 1; + Ok(PrimReceipt { + plan_id: id, + object_id: UUID::new_with_string(format!("00000000-0000-0000-0000-{:012}", *next)) + .unwrap(), + local_id: *next, + }) + }) + } + fn configure_prim( + &self, + _: &PrimReceipt, + prim: &BuildPrim, + _: CancellationToken, + ) -> BuildFuture<'_, ()> { + let id = prim.id.clone(); + Box::pin(async move { self.record(&format!("configure:{id}")) }) + } + fn link_prims( + &self, + _: &PrimReceipt, + child: &PrimReceipt, + _: CancellationToken, + ) -> BuildFuture<'_, ()> { + let id = child.plan_id.clone(); + Box::pin(async move { self.record(&format!("link:{id}")) }) + } + fn insert_script( + &self, + receipt: &PrimReceipt, + _: &str, + _: CancellationToken, + ) -> BuildFuture<'_, ()> { + let id = receipt.plan_id.clone(); + Box::pin(async move { self.record(&format!("script:{id}")) }) + } + fn confirm_prim(&self, receipt: &PrimReceipt, _: CancellationToken) -> BuildFuture<'_, ()> { + let id = receipt.plan_id.clone(); + Box::pin(async move { self.record(&format!("confirm:{id}")) }) + } + fn delete_owned_prim( + &self, + receipt: &PrimReceipt, + _: CancellationToken, + ) -> BuildFuture<'_, ()> { + let id = receipt.plan_id.clone(); + Box::pin(async move { self.record(&format!("delete:{id}")) }) + } +} + +fn prim(id: &str, parent: Option<&str>) -> BuildPrim { + BuildPrim { + id: id.into(), + parent: parent.map(str::to_owned), + shape: BuildShape::Box, + position_millimeters: [1_000, 2_000, 3_000], + scale_millimeters: [500, 500, 500], + rotation_degrees: [0.0, 0.0, 0.0], + color_rgba: [1.0, 0.5, 0.0, 1.0], + material: "wood".into(), + texture_inventory_id: None, + name: id.into(), + description: "safe".into(), + script: None, + } +} +fn plan() -> BuildPlan { + BuildPlan { + version: 1, + region_id: "region-a".into(), + prims: vec![prim("root", None), prim("child", Some("root"))], + } +} + +#[test] +fn validation_rejects_adversarial_and_unbounded_plans() { + let grid = Arc::new(FakeGrid::default()); + let service = BuildService::new(grid, BuildLimits::default()).unwrap(); + let mut cases = Vec::new(); + let mut p = plan(); + p.version = 2; + cases.push(p); + let mut p = plan(); + p.prims[1].parent = Some("child".into()); + cases.push(p); + let mut p = plan(); + p.prims[0].scale_millimeters[0] = 0; + cases.push(p); + let mut p = plan(); + p.prims[0].rotation_degrees[0] = f64::NAN; + cases.push(p); + let mut p = plan(); + p.prims[0].position_millimeters[0] = 99_000; + cases.push(p); + let mut p = plan(); + p.prims[0].material = "money".into(); + cases.push(p); + let mut p = plan(); + p.prims[0].texture_inventory_id = Some("https://evil.invalid/a".into()); + cases.push(p); + let mut p = plan(); + p.prims[0].script = Some( + "default { state_entry() { llHTTPRequest(\"https://evil.invalid\", [], \"\"); } }".into(), + ); + cases.push(p); + assert!(cases.iter().all(|p| service.validate(p).is_err())); +} + +#[tokio::test] +async fn dry_run_checks_rights_and_owned_assets_without_mutation() { + let grid = Arc::new(FakeGrid::default()); + *grid.owned.lock().unwrap() = true; + let service = BuildService::new(grid.clone(), BuildLimits::default()).unwrap(); + let mut p = plan(); + p.prims[0].texture_inventory_id = Some("00000000-0000-0000-0000-000000000099".into()); + let report = service + .dry_run(&p, CancellationToken::default()) + .await + .unwrap(); + assert_eq!(report.prim_count, 2); + assert_eq!(report.link_count, 1); + assert_eq!(grid.calls(), vec!["land", "texture"]); +} + +#[tokio::test] +async fn land_denial_and_cancellation_stop_before_mutation() { + let grid = Arc::new(FakeGrid::default()); + *grid.deny_land.lock().unwrap() = true; + let service = BuildService::new(grid.clone(), BuildLimits::default()).unwrap(); + assert_eq!( + service + .dry_run(&plan(), CancellationToken::default()) + .await + .unwrap_err(), + BuildError::LandDenied + ); + assert!(!grid.calls().iter().any(|call| call.starts_with("create:"))); + let cancelled = CancellationTokenSource::new(); + cancelled.cancel(); + assert_eq!( + service + .execute("tx-cancel", "call", plan(), cancelled.token()) + .await + .unwrap_err(), + BuildError::Cancelled + ); +} + +#[tokio::test] +async fn partial_create_reply_times_out_without_guessing_an_object_identity() { + let grid = Arc::new(FakeGrid::default()); + *grid.hang_create.lock().unwrap() = true; + let limits = BuildLimits { + step_timeout: std::time::Duration::from_millis(1), + ..BuildLimits::default() + }; + let service = BuildService::new(grid, limits).unwrap(); + assert_eq!( + service + .execute("tx-timeout", "call", plan(), CancellationToken::default()) + .await + .unwrap_err(), + BuildError::TimedOut + ); + assert!(service.orphan_ids("tx-timeout").is_empty()); +} + +#[tokio::test] +async fn representative_build_confirms_configures_scripts_links_and_identity() { + let grid = Arc::new(FakeGrid::default()); + let service = BuildService::new(grid.clone(), BuildLimits::default()).unwrap(); + let mut p = plan(); + p.prims[1].script = Some("default { state_entry() { llOwnerSay(\"ready\"); } }".into()); + let receipt = service + .execute("tx-1", "call-1", p, CancellationToken::default()) + .await + .unwrap(); + assert_eq!(receipt.object_ids.len(), 2); + assert_eq!( + receipt.root_object_id, + "00000000-0000-0000-0000-000000000001" + ); + assert_eq!( + grid.calls(), + vec![ + "land", + "create:root", + "confirm:root", + "configure:root", + "confirm:root", + "create:child", + "confirm:child", + "configure:child", + "confirm:child", + "script:child", + "confirm:child", + "link:child", + "confirm:child" + ] + ); + assert_eq!( + service.observations().last().unwrap().kind, + BuildObservationKind::Completed + ); +} + +#[tokio::test] +async fn failure_cleans_only_objects_created_by_transaction_in_reverse_order() { + let grid = Arc::new(FakeGrid::default()); + *grid.fail.lock().unwrap() = vec!["link:child".into()]; + let service = BuildService::new(grid.clone(), BuildLimits::default()).unwrap(); + assert_eq!( + service + .execute("tx-2", "call-2", plan(), CancellationToken::default()) + .await + .unwrap_err(), + BuildError::GridOperation + ); + let calls = grid.calls(); + assert_eq!(&calls[calls.len() - 2..], ["delete:child", "delete:root"]); + assert!(service.orphan_ids("tx-2").is_empty()); +} + +#[tokio::test] +async fn cleanup_failure_records_recoverable_orphan_identity() { + let grid = Arc::new(FakeGrid::default()); + *grid.fail.lock().unwrap() = vec!["configure:root".into(), "delete:root".into()]; + let service = BuildService::new(grid.clone(), BuildLimits::default()).unwrap(); + assert_eq!( + service + .execute("tx-3", "call-3", plan(), CancellationToken::default()) + .await + .unwrap_err(), + BuildError::CleanupIncomplete + ); + assert_eq!(service.orphan_ids("tx-3").len(), 1); + assert!( + service + .observations() + .iter() + .any(|o| o.kind == BuildObservationKind::Orphaned) + ); +} + +#[test] +fn policy_requires_bound_operator_approval_only_above_small_limit() { + let tools = build_policy_tools(BuildLimits::default()).unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].definition.name.as_str(), BUILD_DRY_RUN_TOOL); + assert!(!tools[0].definition.mutating); + assert_eq!(tools[1].definition.name.as_str(), BUILD_EXECUTE_TOOL); + assert!(tools[1].definition.mutating); + assert!(matches!( + tools[1].approval, + crate::ApprovalRule::WhenExceeds(_) + )); + let service = BuildService::new(Arc::new(FakeGrid::default()), BuildLimits::default()).unwrap(); + assert!(!service.validate(&plan()).unwrap().approval_required); + let mut larger = plan(); + for index in 2..5 { + larger + .prims + .push(prim(&format!("child-{index}"), Some("root"))); + } + assert!(service.validate(&larger).unwrap().approval_required); +} + +#[test] +fn build_tools_are_invisible_to_public_and_unauthorized_origins() { + let authorized = UUID::new_with_string("11111111-1111-4111-8111-111111111111".into()).unwrap(); + let stranger = UUID::new_with_string("22222222-2222-4222-8222-222222222222".into()).unwrap(); + let audit = Arc::new(MemoryPolicyAudit::new(32).unwrap()); + let sink: Arc = audit; + let gateway = PolicyGateway::new( + std::collections::BTreeSet::from([authorized]), + build_policy_tools(BuildLimits::default()).unwrap(), + PolicyLimits::default(), + sink, + ) + .unwrap(); + let public = + PolicyRequestContext::new(ActionOrigin::public_chat(authorized), "public", "call").unwrap(); + let unauthorized = + PolicyRequestContext::new(ActionOrigin::instant_message(stranger), "im", "call").unwrap(); + let authorized_im = PolicyRequestContext::new( + ActionOrigin::instant_message(authorized), + "authorized", + "call", + ) + .unwrap(); + assert!(gateway.tools_for(&public, 1).is_empty()); + assert!(gateway.tools_for(&unauthorized, 1).is_empty()); + assert_eq!(gateway.tools_for(&authorized_im, 1).len(), 2); +} diff --git a/crates/metacrate-grid-agent/src/control_plane.rs b/crates/metacrate-grid-agent/src/control_plane.rs index dd16a4a..d537c49 100644 --- a/crates/metacrate-grid-agent/src/control_plane.rs +++ b/crates/metacrate-grid-agent/src/control_plane.rs @@ -199,6 +199,9 @@ pub struct RuntimeView { pub control_queue_capacity: usize, pub budget_tool_calls_used: u64, pub budget_movement_millimeters_used: u64, + pub active_build_transaction: Option, + pub build_progress: Option, + pub build_orphan_ids: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/crates/metacrate-grid-agent/src/control_plane_tests.rs b/crates/metacrate-grid-agent/src/control_plane_tests.rs index 786f587..072d699 100644 --- a/crates/metacrate-grid-agent/src/control_plane_tests.rs +++ b/crates/metacrate-grid-agent/src/control_plane_tests.rs @@ -53,6 +53,9 @@ impl ControlTarget for FakeTarget { control_queue_capacity: 64, budget_tool_calls_used: 3, budget_movement_millimeters_used: 2_000, + active_build_transaction: None, + build_progress: None, + build_orphan_ids: Vec::new(), })), ControlRequest::ListSessions { page } => Ok(ControlPayload::Sessions(page_values( &page, diff --git a/crates/metacrate-grid-agent/src/control_runtime.rs b/crates/metacrate-grid-agent/src/control_runtime.rs index 455bf64..67dd850 100644 --- a/crates/metacrate-grid-agent/src/control_runtime.rs +++ b/crates/metacrate-grid-agent/src/control_runtime.rs @@ -84,6 +84,7 @@ pub struct AgentControlTarget { policy: Arc, audit: Arc, observability: Mutex>>, + build: Mutex>>, behavior: BehaviorIngress, commands: mpsc::Sender, command_capacity: usize, @@ -127,6 +128,7 @@ impl AgentControlTarget { policy, audit, observability: Mutex::new(None), + build: Mutex::new(None), behavior, commands, command_capacity, @@ -142,6 +144,11 @@ impl AgentControlTarget { *lock(&self.observability) = Some(observability); } + /// Attaches transactional build cancellation to the operator action API. + pub fn attach_build_control(&self, build: Arc) { + *lock(&self.build) = Some(build); + } + pub fn update_session(&self, session: SessionStatus) { let mut state = lock(&self.state); state.session = session; @@ -237,6 +244,7 @@ impl AgentControlTarget { ControlRequest::Runtime => { let state = lock(&self.state).clone(); let usage = self.policy.global_budget_usage(); + let build = lock(&self.build); Ok(ControlPayload::Runtime(RuntimeView { grid_state: state.session.state.as_str().to_owned(), generation: state.session.generation, @@ -252,6 +260,11 @@ impl AgentControlTarget { control_queue_capacity: self.command_capacity, budget_tool_calls_used: usage.tool_calls, budget_movement_millimeters_used: usage.movement_millimeters, + active_build_transaction: build.as_ref().and_then(|build| build.active_build()), + build_progress: build.as_ref().and_then(|build| build.build_progress()), + build_orphan_ids: build + .as_ref() + .map_or_else(Vec::new, |build| build.build_orphans()), })) } ControlRequest::ListSessions { page } => { @@ -340,10 +353,13 @@ impl AgentControlTarget { Ok(response) } ControlRequest::CancelAction { action_id } => { + let build_cancelled = lock(&self.build) + .as_ref() + .is_some_and(|build| build.cancel_build(&action_id)); let cancelled = self.behavior.cancel_action(&action_id).map_err(|_| { control_error(ControlErrorCode::InvalidRequest, "invalid action ID", false) })?; - if !cancelled { + if !cancelled && !build_cancelled { return Err(control_error( ControlErrorCode::NotFound, "active behavior action not found", diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index 53b5377..8649652 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -6,6 +6,7 @@ pub mod backend; pub mod behavior; +pub mod build; pub mod config; pub mod control_plane; pub mod control_runtime; @@ -26,6 +27,8 @@ pub mod types; #[cfg(test)] mod behavior_tests; #[cfg(test)] +mod build_tests; +#[cfg(test)] mod control_plane_tests; #[cfg(test)] mod control_runtime_tests; @@ -64,6 +67,13 @@ pub use behavior::{ EmbodimentSink, FACE_AVATAR_TOOL, FACE_POINT_TOOL, LOOK_AROUND_TOOL, SIT_TOOL, STAND_TOOL, STOP_TOOL, WALK_SHORT_TOOL, behavior_policy_tools, }; +#[cfg(feature = "live-grid")] +pub use build::LibremetaverseBuildGrid; +pub use build::{ + BUILD_DRY_RUN_TOOL, BUILD_EXECUTE_TOOL, BuildControl, BuildError, BuildFuture, BuildGrid, + BuildLimits, BuildObservation, BuildObservationKind, BuildPlan, BuildPrim, BuildReceipt, + BuildService, BuildShape, BuildToolBackend, BuildValidation, PrimReceipt, build_policy_tools, +}; pub use config::{ AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, ControlSettings, ConversationSettings, EndpointUrl, Environment, GridConnection, Limits, LlmConnection, diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index 3f9bbcd..10d69ca 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -291,6 +291,7 @@ async fn run_live( config.limits.control_queue, )?; control_target.attach_observability(live.observability.clone()); + control_target.attach_build_control(live.build_control.clone()); control_target.update_session(handle.status()); let erased_target: Arc = control_target.clone(); let (control_plane, integrated_client, control_server) = match config.mode { @@ -490,6 +491,7 @@ struct LiveInteractions { observability: Arc, _landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake, landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle, + build_control: Arc, } #[cfg(feature = "live-grid")] @@ -500,13 +502,14 @@ fn start_live_interactions( ) -> Result> { use metacrate_grid_agent::{ AuthorizedBackendRouter, AuthorizedToolBackend, BehaviorBackend, BehaviorController, - ConversationStore, InteractionCoordinator, LandmarkLimits, LandmarkService, - LandmarkToolBackend, LibremetaverseLandmarkGrid, LibremetaverseLandmarkIntake, - LibremetaverseScriptInventory, LlmClient, LlmTransportLimits, MemoryPolicyAudit, - Observability, ObservabilityLimits, PerceptionBackend, PolicyAuditSink, PolicyGateway, - PolicyLimits, PolicyLlmResponder, ScriptDeliveryBackend, ScriptDeliverySettings, - SystemRoamingRandom, ToolLoopLimits, UnifiedPolicyAudit, behavior_policy_tools, - landmark_policy_tools, perception_policy_tools, script_delivery_policy_tool, + BuildLimits, BuildService, BuildToolBackend, ConversationStore, InteractionCoordinator, + LandmarkLimits, LandmarkService, LandmarkToolBackend, LibremetaverseBuildGrid, + LibremetaverseLandmarkGrid, LibremetaverseLandmarkIntake, LibremetaverseScriptInventory, + LlmClient, LlmTransportLimits, MemoryPolicyAudit, Observability, ObservabilityLimits, + PerceptionBackend, PolicyAuditSink, PolicyGateway, PolicyLimits, PolicyLlmResponder, + ScriptDeliveryBackend, ScriptDeliverySettings, SystemRoamingRandom, ToolLoopLimits, + UnifiedPolicyAudit, behavior_policy_tools, build_policy_tools, landmark_policy_tools, + perception_policy_tools, script_delivery_policy_tool, }; let transport_limits = LlmTransportLimits { @@ -558,6 +561,14 @@ fn start_live_interactions( tools.push(script_delivery_policy_tool( ScriptDeliverySettings::default(), )?); + let build_service = Arc::new(BuildService::new( + Arc::new(LibremetaverseBuildGrid::new(owner)), + BuildLimits::default(), + )?); + let build_control: Arc = build_service.clone(); + let build_backend: Arc = + Arc::new(BuildToolBackend::new(build_service)); + tools.extend(build_policy_tools(BuildLimits::default())?); let landmark_service = Arc::new(LandmarkService::new( Arc::new(LibremetaverseLandmarkGrid::new(owner)), Arc::new(SystemRoamingRandom::default()), @@ -582,16 +593,17 @@ fn start_live_interactions( .iter() .map(|tool| tool.definition.name.as_str().to_owned()) .map(|name| { - let backend: Arc = - if name == metacrate_grid_agent::SCRIPT_DELIVERY_TOOL { - Arc::clone(&script_backend) - } else if name.starts_with("landmark_") { - Arc::clone(&landmark_backend) - } else if name.starts_with("behavior_") { - Arc::new(BehaviorBackend::new(behavior_ingress.clone())) - } else { - perception.clone() - }; + let backend: Arc = if name.starts_with("build_object_") { + Arc::clone(&build_backend) + } else if name == metacrate_grid_agent::SCRIPT_DELIVERY_TOOL { + Arc::clone(&script_backend) + } else if name.starts_with("landmark_") { + Arc::clone(&landmark_backend) + } else if name.starts_with("behavior_") { + Arc::new(BehaviorBackend::new(behavior_ingress.clone())) + } else { + perception.clone() + }; (name, backend) }) .collect::>(); @@ -649,6 +661,7 @@ fn start_live_interactions( observability, _landmark_intake: landmark_intake, landmark_roaming, + build_control, }) } diff --git a/crates/metacrate-grid-agent/src/tui.rs b/crates/metacrate-grid-agent/src/tui.rs index 00574a9..036395f 100644 --- a/crates/metacrate-grid-agent/src/tui.rs +++ b/crates/metacrate-grid-agent/src/tui.rs @@ -709,6 +709,14 @@ fn render_overview(s: &OperatorSnapshot, out: &mut Vec) { v.position, v.behavior_mode )); + if v.active_build_transaction.is_some() || !v.build_orphan_ids.is_empty() { + out.push(format!( + "build={} progress={} recoverable-orphans={}", + v.active_build_transaction.as_deref().unwrap_or("idle"), + v.build_progress.as_deref().unwrap_or("none"), + v.build_orphan_ids.len() + )); + } } } fn render_queues(s: &OperatorSnapshot, out: &mut Vec) { diff --git a/crates/metacrate-grid-agent/src/tui_tests.rs b/crates/metacrate-grid-agent/src/tui_tests.rs index 4525ef7..3654179 100644 --- a/crates/metacrate-grid-agent/src/tui_tests.rs +++ b/crates/metacrate-grid-agent/src/tui_tests.rs @@ -43,6 +43,9 @@ impl TuiTransport for FakeTransport { control_queue_capacity: 8, budget_tool_calls_used: 2, budget_movement_millimeters_used: 3, + active_build_transaction: Some("build-7".into()), + build_progress: Some("configured".into()), + build_orphan_ids: Vec::new(), }), ControlRequest::ListSessions { .. } => ControlPayload::Sessions(crate::Page { items: Vec::new(), diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 7365406..2eae49c 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -48,10 +48,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() { #[test] fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() { let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::with_capacity(32); + let mut files = Vec::with_capacity(34); collect_rust_files(&source, &mut files); assert!( - files.len() <= 32, + files.len() <= 34, "source-file count needs a reviewed bound update" ); for path in files { diff --git a/crates/metacrate/Cargo.toml b/crates/metacrate/Cargo.toml index 12dc1b8..2097896 100644 --- a/crates/metacrate/Cargo.toml +++ b/crates/metacrate/Cargo.toml @@ -11,5 +11,9 @@ description = "Native MetaCrate APIs outside the LibreMetaverse compatibility su metacrate-grid-agent = { version = "0.0.1", path = "../metacrate-grid-agent" } metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" } +[features] +default = [] +live-grid = ["metacrate-grid-agent/live-grid"] + [lints] workspace = true