//! `invoke()` Rhai bridge — synchronous, same-app, re-entrant. //! //! ```rhai //! let result = invoke("/api/payments/process", #{ order_id: 123 }); //! let result = invoke(script_id("uuid-..."), args); //! ``` //! //! Re-entrancy: the bridge captures `Arc` (via //! `Engine::self_arc`) and calls `engine.execute(&source, req)` directly //! inside the caller's `spawn_blocking` thread. Same engine instance, //! same `Services`, same `Limits` — only the per-call `SdkCallCx` //! changes (the callee gets `trigger_depth + 1` and a fresh //! `execution_id`). //! //! Cross-app rejection happens at the `InvokeService::resolve` layer — //! every method derives `app_id` from `cx.app_id` and returns //! `InvokeError::CrossApp` if the resolved script belongs elsewhere. //! //! Depth bound: `cx.trigger_depth + 1 > limits.trigger_depth_max` → //! `InvokeError::DepthExceeded`. Shared with the trigger fan-out depth //! limit per the design notes (no separate `invoke_depth_max`). //! //! Closures captured across the `invoke` boundary are rejected at the //! args-to-JSON conversion — `FnPtr` doesn't survive serialization and //! re-entry into a fresh engine instance is the wrong scope for it //! anyway. //! //! `invoke_async()` writes an outbox row (see `InvokeService::enqueue_async`) //! and returns immediately with the new `ExecutionId` as a string. The //! dispatcher fires it through the standard executor path; commit 10 //! wires the dispatcher's `OutboxSourceKind::Invoke` arm. use std::collections::BTreeMap; use std::sync::Arc; use base64::engine::general_purpose::STANDARD; use base64::Engine as _; use picloud_shared::{ExecutionId, InvokeService, InvokeTarget, ScriptId, SdkCallCx, Services}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use serde_json::Value as Json; use tokio::runtime::Handle as TokioHandle; use crate::engine::Engine; use crate::sandbox::Limits; use crate::sdk::bridge::{json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES}; use crate::types::{ExecRequest, InvocationType}; pub(super) fn register( engine: &mut RhaiEngine, services: &Services, cx: Arc, limits: Limits, self_engine: Option>, ) { let svc = services.invoke.clone(); let mut module = Module::new(); // Sync `invoke(target, args)` — returns the callee's return value. { let svc = svc.clone(); let cx = cx.clone(); let self_engine = self_engine.clone(); module.set_native_fn( "invoke", move |target: Dynamic, args: Dynamic| -> Result> { let target = parse_target(target)?; let args_json = args_to_json(&args)?; let self_engine = self_engine.clone().ok_or_else(|| -> Box { EvalAltResult::ErrorRuntime( "invoke: engine back-reference not installed (test setup missing set_self_weak?)".into(), rhai::Position::NONE, ) .into() })?; invoke_blocking(&svc, &cx, target, args_json, limits, &self_engine) }, ); } // `invoke_async(target, args)` — fire-and-forget; returns the new // ExecutionId as a string. { let svc = svc.clone(); let cx = cx.clone(); module.set_native_fn( "invoke_async", move |target: Dynamic, args: Dynamic| -> Result> { // Audit #2: count this durable emission against the per-execution // fan-out ceiling before doing any work. crate::sdk::emit_budget::charge_emission("invoke_async")?; let target = parse_target(target)?; let args_json = args_to_json(&args)?; let svc = svc.clone(); let cx = cx.clone(); let handle = TokioHandle::try_current().map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("invoke_async: no tokio runtime: {e}").into(), rhai::Position::NONE, ) .into() })?; let exec_id = handle .block_on(async move { svc.enqueue_async(&cx, target, args_json).await }) .map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("invoke_async: {e}").into(), rhai::Position::NONE, ) .into() })?; Ok(exec_id.to_string()) }, ); } // Register globally — `invoke(...)` and `invoke_async(...)` are // top-level helpers, not under a `::` namespace, mirroring the // brief's syntax. engine.register_global_module(module.into()); } /// The sync invoke path. Runs entirely inside the caller's /// `spawn_blocking` thread; no gate (the outer execution is already /// gate-admitted), no new spawn_blocking (we'd deadlock if we nested /// inside the blocking pool). fn invoke_blocking( svc: &Arc, cx: &Arc, target: InvokeTarget, args_json: Json, limits: Limits, self_engine: &Arc, ) -> Result> { let handle = TokioHandle::try_current().map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("invoke: no tokio runtime: {e}").into(), rhai::Position::NONE, ) .into() })?; // Depth check — done BEFORE resolve so a depth-exceeded loop // doesn't waste a DB round-trip. if cx.trigger_depth + 1 > limits.trigger_depth_max { return Err(EvalAltResult::ErrorRuntime( format!( "invoke: depth limit exceeded (max {})", limits.trigger_depth_max ) .into(), rhai::Position::NONE, ) .into()); } let svc_clone = svc.clone(); let cx_clone = cx.clone(); let target_label = target.describe(); let resolved = handle .block_on(async move { svc_clone.resolve(&cx_clone, target).await }) .map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("invoke({target_label}): {e}").into(), rhai::Position::NONE, ) .into() })?; // The callee's return is the response `body` JSON. Convert back to // Dynamic for the caller. Status code + headers are dropped; the // function-call mental model is "return value", not HTTP response. let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?; Ok(json_to_dynamic(body)) } /// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting /// the caller's app/principal/root/depth+1 and the callee's lexical owner), /// compile through the per-Engine AST cache (F-P-004), execute, and return the /// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook — /// the caller is responsible for the depth check (both do it before resolving). /// `label` prefixes any compile/execute error. /// Run a resolved script through the `invoke()` re-entry core, inheriting the /// caller's ambient execution deadline. pub(super) fn run_resolved_blocking( self_engine: &Arc, cx: &Arc, resolved: &picloud_shared::ResolvedScript, body_json: Json, label: &str, ) -> Result> { run_resolved_core( self_engine, cx, resolved, body_json, label, crate::engine::ambient_deadline(), ) } /// Like [`run_resolved_blocking`] but bounds the run by a per-hook `timeout` /// (§9.4 M5). The effective deadline is `min(ambient, now + timeout)` — a hook /// can only ever TIGHTEN, never extend, its caller's deadline. pub(super) fn run_resolved_blocking_with_timeout( self_engine: &Arc, cx: &Arc, resolved: &picloud_shared::ResolvedScript, body_json: Json, label: &str, timeout: Option, ) -> Result> { let ambient = crate::engine::ambient_deadline(); let own = timeout.map(|d| std::time::Instant::now() + d); let effective = match (ambient, own) { (Some(a), Some(o)) => Some(a.min(o)), (a, None) => a, (None, o) => o, }; run_resolved_core(self_engine, cx, resolved, body_json, label, effective) } fn run_resolved_core( self_engine: &Arc, cx: &Arc, resolved: &picloud_shared::ResolvedScript, body_json: Json, label: &str, deadline: Option, ) -> Result> { let req = ExecRequest { execution_id: ExecutionId::new(), request_id: cx.request_id, script_id: resolved.script_id, script_name: resolved.name.clone(), invocation_type: InvocationType::Function, path: "/invoke".into(), method: String::new(), headers: BTreeMap::new(), body: body_json, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), sandbox_overrides: picloud_shared::ScriptSandbox::default(), app_id: cx.app_id, // Lexical origin (§5.5): the callee's defining node — a group // script's imports resolve from the group even when invoked by an // app. `None` falls back to `App(cx.app_id)` in the engine. script_owner: resolved.owner, // Same-app re-entry is not a re-auth boundary — inherit the principal. principal: cx.principal.clone(), trigger_depth: cx.trigger_depth + 1, root_execution_id: cx.root_execution_id, is_dead_letter_handler: cx.is_dead_letter_handler, event: None, }; let ast = self_engine .compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source) .map_err(|e| -> Box { EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into() })?; let resp = self_engine .execute_ast_with_deadline(&ast, req, deadline) .map_err(|e| -> Box { EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into() })?; Ok(resp.body) } /// Accept a string (route path OR script name) or a Rhai script-id /// custom type. The string heuristic: starts with '/' → Path, else Name. fn parse_target(target: Dynamic) -> Result> { if let Ok(s) = target.clone().into_string() { if s.starts_with('/') { return Ok(InvokeTarget::Path(s)); } // Heuristic: 36-char UUID-like string → Id; else Name. Keeps // `invoke("payments_worker")` and `invoke("550e8400-...")` both // working without a separate `script_id()` constructor in v1.1.9. if s.len() == 36 { if let Ok(uuid) = uuid::Uuid::parse_str(&s) { return Ok(InvokeTarget::Id(ScriptId::from(uuid))); } } return Ok(InvokeTarget::Name(s)); } let _ = target; Err(EvalAltResult::ErrorRuntime( "invoke: target must be a string (path or name) or a script_id".into(), rhai::Position::NONE, ) .into()) } /// Convert Rhai Dynamic → JSON. Rejects FnPtr at any depth (closures /// don't survive invoke boundaries) and is bounded by /// [`MAX_JSON_MATERIALIZE_BYTES`] so a cheaply-aliased huge args value can't OOM /// the node on materialization (same anti-OOM rail as `dynamic_to_json_capped`). fn args_to_json(value: &Dynamic) -> Result> { let mut remaining = MAX_JSON_MATERIALIZE_BYTES; args_to_json_bounded(value, &mut remaining) } fn args_charge(remaining: &mut usize, n: usize) -> Result<(), Box> { match remaining.checked_sub(n) { Some(left) => { *remaining = left; Ok(()) } None => Err(EvalAltResult::ErrorRuntime( format!( "invoke: args too large to serialize (exceeds the {MAX_JSON_MATERIALIZE_BYTES}-byte limit)" ) .into(), rhai::Position::NONE, ) .into()), } } fn args_to_json_bounded( value: &Dynamic, remaining: &mut usize, ) -> Result> { if value.is::() { return Err(EvalAltResult::ErrorRuntime( "invoke: args must not contain FnPtr / closures".into(), rhai::Position::NONE, ) .into()); } if value.is_blob() { let blob = value.clone().into_blob().unwrap_or_default(); let encoded = STANDARD.encode(&blob); args_charge(remaining, encoded.len() + 2)?; return Ok(Json::String(encoded)); } if value.is_unit() { args_charge(remaining, 4)?; return Ok(Json::Null); } if let Ok(b) = value.as_bool() { args_charge(remaining, 5)?; return Ok(Json::Bool(b)); } if let Ok(i) = value.as_int() { args_charge(remaining, 8)?; return Ok(Json::Number(i.into())); } if let Ok(f) = value.as_float() { args_charge(remaining, 8)?; return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number)); } if value.is_string() { let s = value.clone().into_string().unwrap_or_default(); args_charge(remaining, s.len() + 2)?; return Ok(Json::String(s)); } if let Some(arr) = value.clone().try_cast::() { args_charge(remaining, 2)?; let mut out = Vec::with_capacity(arr.len().min(1024)); for v in &arr { args_charge(remaining, 1)?; out.push(args_to_json_bounded(v, remaining)?); } return Ok(Json::Array(out)); } if let Some(map) = value.clone().try_cast::() { args_charge(remaining, 2)?; let mut out = serde_json::Map::new(); for (k, v) in map { args_charge(remaining, k.len() + 4)?; out.insert(k.to_string(), args_to_json_bounded(&v, remaining)?); } return Ok(Json::Object(out)); } let s = value.to_string(); args_charge(remaining, s.len() + 2)?; Ok(Json::String(s)) } /// `Limits` is `Copy`; passed by value into `register` so closures take /// owned copies. Hint for readers wondering why it doesn't need `Arc`. #[allow(dead_code)] const _LIMITS_IS_COPY: fn() = || { fn assert_copy() {} assert_copy::(); }; #[cfg(test)] mod tests { use super::*; #[test] fn parse_target_path() { let d = Dynamic::from("/api/foo"); let t = parse_target(d).unwrap(); assert!(matches!(t, InvokeTarget::Path(_))); } #[test] fn parse_target_uuid_string_is_id() { let d = Dynamic::from("550e8400-e29b-41d4-a716-446655440000"); let t = parse_target(d).unwrap(); assert!(matches!(t, InvokeTarget::Id(_))); } #[test] fn parse_target_plain_name() { let d = Dynamic::from("payments_worker"); let t = parse_target(d).unwrap(); match t { InvokeTarget::Name(n) => assert_eq!(n, "payments_worker"), other => panic!("expected Name, got {other:?}"), } } #[test] fn args_to_json_rejects_fnptr() { let f = rhai::FnPtr::new("x").unwrap(); let d = Dynamic::from(f); assert!(args_to_json(&d).is_err()); } #[test] fn args_to_json_round_trips_map() { let mut m = Map::new(); m.insert("x".into(), Dynamic::from(42_i64)); let d = Dynamic::from(m); let j = args_to_json(&d).unwrap(); assert_eq!(j, serde_json::json!({ "x": 42 })); } }