Carry the resolved script's owner (its defining node) from dispatch into the executor so `import` resolution can be lexical (§5.5). The executing app (`app_id`) stays the SDK isolation boundary; `script_owner` is a separate axis — the lexical origin for imports. - `ExecRequest.script_owner: Option<ScriptOwner>` (serde default; `None` falls back to `App(app_id)` in the engine for old payloads / cluster wire). - `ScriptOwner` gains `Serialize`/`Deserialize` for the wire. - The engine computes `default_origin` and hands it to the resolver (used fully in C3; the resolve() lookup already uses it). - Every dispatch site sets it from the resolved `Script.owner()`: the 4 dispatcher arms (queue/trigger/http/invoke_async, via a new `ResolvedTrigger.script_owner`), the orchestrator id-bypass, and the `invoke()` SDK path (new `ResolvedScript.owner`, so a group script invoked by an app resolves imports from the group). Behaviour-preserving: app scripts still resolve `App(app_id)`; group scripts can't yet carry imports (lifted in C4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
//! `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<Engine>` (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;
|
|
use crate::types::{ExecRequest, InvocationType};
|
|
|
|
pub(super) fn register(
|
|
engine: &mut RhaiEngine,
|
|
services: &Services,
|
|
cx: Arc<SdkCallCx>,
|
|
limits: Limits,
|
|
self_engine: Option<Arc<Engine>>,
|
|
) {
|
|
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<Dynamic, Box<EvalAltResult>> {
|
|
let target = parse_target(target)?;
|
|
let args_json = args_to_json(&args)?;
|
|
let self_engine = self_engine.clone().ok_or_else(|| -> Box<EvalAltResult> {
|
|
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<String, Box<EvalAltResult>> {
|
|
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> {
|
|
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> {
|
|
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<dyn InvokeService>,
|
|
cx: &Arc<SdkCallCx>,
|
|
target: InvokeTarget,
|
|
args_json: Json,
|
|
limits: Limits,
|
|
self_engine: &Arc<Engine>,
|
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
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> {
|
|
EvalAltResult::ErrorRuntime(
|
|
format!("invoke({target_label}): {e}").into(),
|
|
rhai::Position::NONE,
|
|
)
|
|
.into()
|
|
})?;
|
|
|
|
let execution_id = ExecutionId::new();
|
|
let req = ExecRequest {
|
|
execution_id,
|
|
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: args_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 invoke is a function call, not a re-auth boundary —
|
|
// inherit the caller's 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,
|
|
};
|
|
|
|
// F-P-004: synchronous re-entry — route through the per-Engine
|
|
// AST cache so each callee parses once per (script_id, updated_at),
|
|
// not once per invoke. Composed workflows multiply parse cost by
|
|
// depth; the cache cuts that to constant compile + N executions.
|
|
let ast = self_engine
|
|
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
|
.map_err(|e| -> Box<EvalAltResult> {
|
|
EvalAltResult::ErrorRuntime(
|
|
format!("invoke({target_label}): {e}").into(),
|
|
rhai::Position::NONE,
|
|
)
|
|
.into()
|
|
})?;
|
|
let resp = self_engine
|
|
.execute_ast(&ast, req)
|
|
.map_err(|e| -> Box<EvalAltResult> {
|
|
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.
|
|
Ok(json_to_dynamic(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<InvokeTarget, Box<EvalAltResult>> {
|
|
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).
|
|
fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
|
if value.is::<rhai::FnPtr>() {
|
|
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();
|
|
return Ok(Json::String(STANDARD.encode(&blob)));
|
|
}
|
|
if value.is_unit() {
|
|
return Ok(Json::Null);
|
|
}
|
|
if let Ok(b) = value.as_bool() {
|
|
return Ok(Json::Bool(b));
|
|
}
|
|
if let Ok(i) = value.as_int() {
|
|
return Ok(Json::Number(i.into()));
|
|
}
|
|
if let Ok(f) = value.as_float() {
|
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
|
}
|
|
if value.is_string() {
|
|
return Ok(Json::String(
|
|
value.clone().into_string().unwrap_or_default(),
|
|
));
|
|
}
|
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
|
let mut out = Vec::with_capacity(arr.len());
|
|
for v in &arr {
|
|
out.push(args_to_json(v)?);
|
|
}
|
|
return Ok(Json::Array(out));
|
|
}
|
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
|
let mut out = serde_json::Map::new();
|
|
for (k, v) in map {
|
|
out.insert(k.to_string(), args_to_json(&v)?);
|
|
}
|
|
return Ok(Json::Object(out));
|
|
}
|
|
Ok(Json::String(value.to_string()))
|
|
}
|
|
|
|
/// `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<T: Copy>() {}
|
|
assert_copy::<Limits>();
|
|
};
|
|
|
|
#[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 }));
|
|
}
|
|
}
|