1479 lines
52 KiB
Rust
1479 lines
52 KiB
Rust
//! 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<String>,
|
|
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<String>,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub script: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BuildPlan {
|
|
pub version: u32,
|
|
pub region_id: String,
|
|
pub prims: Vec<BuildPrim>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub orphan_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
pub struct BuildCleanupReceipt {
|
|
pub transaction_id: String,
|
|
pub deleted_object_ids: Vec<String>,
|
|
pub orphan_ids: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub object_id: Option<String>,
|
|
}
|
|
|
|
#[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<Box<dyn Future<Output = Result<T, BuildError>> + 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<String>;
|
|
fn build_orphans(&self) -> Vec<String>;
|
|
fn build_progress(&self) -> Option<String>;
|
|
}
|
|
|
|
struct State {
|
|
active: Option<String>,
|
|
cancelled: BTreeSet<String>,
|
|
observations: VecDeque<BuildObservation>,
|
|
orphans: BTreeMap<String, Vec<PrimReceipt>>,
|
|
completed: BTreeMap<String, Vec<PrimReceipt>>,
|
|
}
|
|
pub struct BuildService<G: BuildGrid> {
|
|
grid: Arc<G>,
|
|
limits: BuildLimits,
|
|
state: Mutex<State>,
|
|
}
|
|
|
|
impl<G: BuildGrid> BuildService<G> {
|
|
pub fn new(grid: Arc<G>, limits: BuildLimits) -> Result<Self, BuildError> {
|
|
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<BuildObservation> {
|
|
lock(&self.state).observations.iter().cloned().collect()
|
|
}
|
|
#[must_use]
|
|
pub fn orphan_ids(&self, transaction: &str) -> Vec<UUID> {
|
|
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<UUID>,
|
|
) {
|
|
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<BuildValidation, BuildError> {
|
|
validate_plan(plan, self.limits)
|
|
}
|
|
pub async fn dry_run(
|
|
&self,
|
|
plan: &BuildPlan,
|
|
cancellation: CancellationToken,
|
|
) -> Result<BuildValidation, BuildError> {
|
|
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::<Vec<_>>(),
|
|
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<BuildReceipt, BuildError> {
|
|
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<BuildReceipt, BuildError> {
|
|
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<BuildCleanupReceipt, BuildError> {
|
|
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::<Vec<_>>();
|
|
let orphan_set = orphaned
|
|
.iter()
|
|
.map(|receipt| receipt.object_id)
|
|
.collect::<BTreeSet<_>>();
|
|
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<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),
|
|
);
|
|
}
|
|
}
|
|
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<G: BuildGrid> BuildControl for BuildService<G> {
|
|
fn cancel_build(&self, transaction_id: &str) -> bool {
|
|
self.cancel(transaction_id)
|
|
}
|
|
fn active_build(&self) -> Option<String> {
|
|
lock(&self.state).active.clone()
|
|
}
|
|
fn build_orphans(&self) -> Vec<String> {
|
|
lock(&self.state)
|
|
.orphans
|
|
.values()
|
|
.flatten()
|
|
.map(|receipt| receipt.object_id.to_string())
|
|
.collect()
|
|
}
|
|
fn build_progress(&self) -> Option<String> {
|
|
lock(&self.state)
|
|
.observations
|
|
.back()
|
|
.map(|observation| format!("{:?}", observation.kind).to_ascii_lowercase())
|
|
}
|
|
}
|
|
|
|
async fn timed<T>(duration: Duration, future: BuildFuture<'_, T>) -> Result<T, BuildError> {
|
|
tokio::time::timeout(duration, future)
|
|
.await
|
|
.map_err(|_| BuildError::TimedOut)?
|
|
}
|
|
fn lock<T>(value: &Mutex<T>) -> 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<BuildValidation, BuildError> {
|
|
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<ResourceCost, PolicyReasonCode> {
|
|
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<Vec<PolicyTool>, 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 receive an autonomous safety review",
|
|
)?,
|
|
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<G: BuildGrid> {
|
|
service: Arc<BuildService<G>>,
|
|
}
|
|
impl<G: BuildGrid> BuildToolBackend<G> {
|
|
#[must_use]
|
|
pub fn new(service: Arc<BuildService<G>>) -> Self {
|
|
Self { service }
|
|
}
|
|
}
|
|
impl<G: BuildGrid> AuthorizedToolBackend for BuildToolBackend<G> {
|
|
fn apply(
|
|
&self,
|
|
action: AuthorizedAction,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
|
|
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::<BuildArgs>(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::<BuildArgs>(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::<CleanupArgs>(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::<MAX_BODY_BYTES>::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::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
};
|
|
ToolCallOutcome::Rejected {
|
|
call_id,
|
|
reason: BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::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<libremetaverse::AgentManager>,
|
|
objects: libremetaverse::ObjectManager,
|
|
inventory: libremetaverse::InventoryManager,
|
|
resolved_textures: Mutex<BTreeMap<UUID, UUID>>,
|
|
}
|
|
|
|
#[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<libremetaverse::Simulator, BuildError> {
|
|
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 (region_size_x, region_size_y) = simulator.region_size();
|
|
let reported_bounds_contain_agent =
|
|
base.x <= region_size_x as f32 && base.y <= region_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 > region_size_x as f32 || point.y > region_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)
|
|
})
|
|
}
|
|
}
|