feat(v1.1.9): invoke:: Rhai SDK module — sync re-entry + invoke_async

executor-core/sdk/invoke.rs adds `invoke()` and `invoke_async()` as
top-level Rhai helpers (no `::` namespace, mirroring the brief):

  invoke(target, args)        -> Dynamic  (sync, returns callee's value)
  invoke_async(target, args)  -> String   (fire-and-forget; returns execution_id)

Sync re-entry pattern:
  - bridge captures Arc<Engine> via Engine::self_arc()
  - calls engine.execute(&source, req) directly — same engine instance,
    same Services, same Limits; only SdkCallCx changes per call
  - runs inside the caller's spawn_blocking thread (no nested
    spawn_blocking, no gate re-admission — the outer execution already
    holds a permit)

Back-reference plumbing:
  - Engine gains self_weak: OnceLock<Weak<Engine>> + set_self_weak() +
    self_arc() accessor
  - picloud binary calls engine.set_self_weak(Arc::downgrade(&engine))
    right after construction
  - register_all extended with limits + Option<Arc<Engine>> params
  - Engine::execute_ast threads both through

Limits.trigger_depth_max added — mirrors TriggerConfig::max_trigger_depth.
Default 8; the picloud binary syncs from TriggerConfig::from_env() so
PICLOUD_MAX_TRIGGER_DEPTH governs both the dispatcher's fan-out cap and
the invoke depth bound.

Target parsing (string-first):
  - "/api/foo"             → InvokeTarget::Path
  - 36-char UUID-like      → InvokeTarget::Id  (uuid::Uuid parse-validated)
  - everything else        → InvokeTarget::Name

Cross-app + depth + FnPtr guards:
  - resolve() returns InvokeError::CrossApp if resolved.app_id != cx.app_id
  - bridge throws "invoke: depth limit exceeded (max N)" when
    cx.trigger_depth + 1 > limits.trigger_depth_max (checked BEFORE
    resolve to avoid a wasted DB round-trip)
  - args_to_json rejects FnPtr at any depth — closures don't survive
    invoke boundaries

invoke_async path:
  - calls services.invoke.enqueue_async() which writes an
    OutboxSourceKind::Invoke row (commit 10 wires the dispatcher arm)
  - returns the new ExecutionId as a string for caller tracking

Integration tests (sdk_invoke.rs, 6 binaries):
  - sync invoke returns callee's value (script returns body.x + 1)
  - cross-app invoke rejected (target in other app)
  - depth limit exceeded (recursive script that calls itself; throws
    before stack overflow)
  - callee receives incremented depth in cx (smoke — strict assertion
    needs ctx.trigger_depth surface, deferred to v1.2)
  - args_to_json rejects FnPtr in payload
  - invoke_async returns valid UUID string + queues args payload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 19:56:05 +02:00
parent 563c44ad95
commit 302c1df577
6 changed files with 657 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Instant;
use chrono::Utc;
@@ -44,6 +44,14 @@ pub struct Engine {
/// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
/// at resolver time.
module_cache: Arc<ModuleCache>,
/// v1.1.9: back-reference set by the picloud binary after
/// `Arc::new(Engine::new(...))`. The `invoke` SDK bridge reads it
/// to re-enter the engine synchronously for `invoke()`. `Weak` so
/// holding the Engine in an Arc doesn't create a strong cycle.
/// `None` until `set_self_weak` runs; the bridge surfaces a clear
/// error if invoke is called without the back-reference being set
/// (which only happens in tests that don't wire it).
self_weak: OnceLock<Weak<Engine>>,
}
impl Engine {
@@ -67,9 +75,31 @@ impl Engine {
limits,
services,
module_cache: new_module_cache(module_cache_capacity),
self_weak: OnceLock::new(),
}
}
/// v1.1.9: install the back-reference used by the `invoke` SDK
/// bridge for synchronous re-entry. Idempotent (subsequent calls
/// are no-ops). The picloud binary calls this right after
/// `Arc::new(Engine::new(...))`:
///
/// ```ignore
/// let engine = Arc::new(Engine::new(limits, services));
/// engine.set_self_weak(Arc::downgrade(&engine));
/// ```
pub fn set_self_weak(&self, weak: Weak<Engine>) {
let _ = self_weak_set(&self.self_weak, weak);
}
/// Internal accessor used by the `invoke` SDK bridge — returns
/// `Some(strong)` if the back-reference was installed and the
/// engine is still alive.
#[must_use]
pub fn self_arc(&self) -> Option<Arc<Engine>> {
self.self_weak.get().and_then(Weak::upgrade)
}
#[must_use]
pub fn limits(&self) -> &Limits {
&self.limits
@@ -164,7 +194,8 @@ impl Engine {
effective_limits.module_import_depth_max,
);
engine.set_module_resolver(resolver);
sdk::register_all(&mut engine, &self.services, cx);
let self_engine = self.self_arc();
sdk::register_all(&mut engine, &self.services, cx, effective_limits, self_engine);
let mut scope = Scope::new();
scope.push_constant("ctx", build_ctx_map(&req));
@@ -211,6 +242,12 @@ impl ScriptValidator for Engine {
}
}
/// Tiny helper to make the `set_self_weak` body idempotent without
/// pulling the impl up into the public API.
fn self_weak_set(slot: &OnceLock<Weak<Engine>>, weak: Weak<Engine>) -> Result<(), Weak<Engine>> {
slot.set(weak)
}
// ----------------------------------------------------------------------------
// Engine construction
// ----------------------------------------------------------------------------