Implement transactional scripted prim builds (#131)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m46s
CI / required (push) Failing after 53s

This commit is contained in:
2026-08-18 11:32:32 +02:00
parent 08696cd2fe
commit 64edd8df46
11 changed files with 1766 additions and 20 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<Vec<String>>,
fail: Mutex<Vec<String>>,
deny_land: Mutex<bool>,
owned: Mutex<bool>,
next: Mutex<u32>,
hang_create: Mutex<bool>,
}
impl FakeGrid {
fn calls(&self) -> Vec<String> {
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<dyn PolicyAuditSink> = 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);
}

View File

@@ -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<String>,
pub build_progress: Option<String>,
pub build_orphan_ids: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]

View File

@@ -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,

View File

@@ -84,6 +84,7 @@ pub struct AgentControlTarget {
policy: Arc<PolicyGateway>,
audit: Arc<MemoryPolicyAudit>,
observability: Mutex<Option<Arc<Observability>>>,
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
behavior: BehaviorIngress,
commands: mpsc::Sender<RuntimeControlCommand>,
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<dyn crate::build::BuildControl>) {
*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",

View File

@@ -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,

View File

@@ -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<dyn ControlTarget> = control_target.clone();
let (control_plane, integrated_client, control_server) = match config.mode {
@@ -490,6 +491,7 @@ struct LiveInteractions {
observability: Arc<metacrate_grid_agent::Observability>,
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
}
#[cfg(feature = "live-grid")]
@@ -500,13 +502,14 @@ fn start_live_interactions(
) -> Result<LiveInteractions, Box<dyn Error>> {
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<dyn metacrate_grid_agent::BuildControl> = build_service.clone();
let build_backend: Arc<dyn AuthorizedToolBackend> =
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,8 +593,9 @@ fn start_live_interactions(
.iter()
.map(|tool| tool.definition.name.as_str().to_owned())
.map(|name| {
let backend: Arc<dyn AuthorizedToolBackend> =
if name == metacrate_grid_agent::SCRIPT_DELIVERY_TOOL {
let backend: Arc<dyn AuthorizedToolBackend> = 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)
@@ -649,6 +661,7 @@ fn start_live_interactions(
observability,
_landmark_intake: landmark_intake,
landmark_roaming,
build_control,
})
}

View File

@@ -709,6 +709,14 @@ fn render_overview(s: &OperatorSnapshot, out: &mut Vec<String>) {
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<String>) {

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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