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>
144 lines
5.0 KiB
Rust
144 lines
5.0 KiB
Rust
//! `InvokeService` — the v1.1.9 function-composition contract.
|
|
//!
|
|
//! Backs the Rhai `invoke(target, args)` and `invoke_async(target, args)`
|
|
//! SDK calls. Resolves a target (by route path, by script name, or by
|
|
//! script id) within the **caller's app** to a `ResolvedScript`. Same-
|
|
//! app only; cross-app calls are rejected at the service entry point
|
|
//! (v1.1.x maintains strict isolation; cross-app sharing arrives in
|
|
//! v1.3+).
|
|
//!
|
|
//! The re-entrant Rhai pattern lives in `executor-core`: the bridge
|
|
//! captures `Arc<Engine>`, resolves the target via this trait, builds a
|
|
//! fresh `ExecRequest` with `trigger_depth + 1`, and calls
|
|
//! `Engine::execute(&source, req)` synchronously inside the caller's
|
|
//! `spawn_blocking` thread. Engine + AST cache are shared; only the
|
|
//! `SdkCallCx` changes per call.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use thiserror::Error;
|
|
|
|
use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx};
|
|
|
|
/// Identifies the script to invoke. Accepted by `invoke()` and
|
|
/// `invoke_async()` script-side.
|
|
///
|
|
/// - `Path("/users/:id")` → resolve via the orchestrator's route trie.
|
|
/// - `Name("payments_worker")` → `(cx.app_id, name) → script_id`.
|
|
/// - `Id(...)` → direct lookup, same-app verification afterward.
|
|
#[derive(Debug, Clone)]
|
|
pub enum InvokeTarget {
|
|
Path(String),
|
|
Name(String),
|
|
Id(ScriptId),
|
|
}
|
|
|
|
impl InvokeTarget {
|
|
/// Best-effort summary for error messages.
|
|
#[must_use]
|
|
pub fn describe(&self) -> String {
|
|
match self {
|
|
Self::Path(p) => format!("path {p:?}"),
|
|
Self::Name(n) => format!("name {n:?}"),
|
|
Self::Id(i) => format!("id {i}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The script the invoke service resolved. `app_id` MUST match the
|
|
/// caller's `cx.app_id` — the service enforces this and returns
|
|
/// `InvokeError::CrossApp` otherwise.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ResolvedScript {
|
|
pub script_id: ScriptId,
|
|
pub app_id: AppId,
|
|
/// The resolved script's **defining node** (Phase 4b) — the lexical
|
|
/// origin its `import`s resolve against (§5.5). For an inherited group
|
|
/// script this is the **group**, even though `app_id` (the execution
|
|
/// boundary) is the caller's app. `None` only for a corrupt owner row;
|
|
/// the executor falls back to `App(app_id)`.
|
|
pub owner: Option<ScriptOwner>,
|
|
pub source: String,
|
|
pub updated_at: DateTime<Utc>,
|
|
/// Script.name — surfaced to the callee as `ctx.script_name`.
|
|
pub name: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait InvokeService: Send + Sync {
|
|
/// Resolve a target to its script + verify same-app. Used by both
|
|
/// `invoke()` (sync) and `invoke_async()` (fire-and-forget).
|
|
async fn resolve(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
target: InvokeTarget,
|
|
) -> Result<ResolvedScript, InvokeError>;
|
|
|
|
/// Fire-and-forget: write a v1.1.9 `OutboxSourceKind::Invoke` row
|
|
/// the dispatcher consumes; return the new `ExecutionId`. No retry
|
|
/// (one shot); the script wraps in `retry::with` if it wants more.
|
|
async fn enqueue_async(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
target: InvokeTarget,
|
|
args: serde_json::Value,
|
|
) -> Result<ExecutionId, InvokeError>;
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum InvokeError {
|
|
/// Target script not found in `cx.app_id`.
|
|
#[error("invoke: target not found ({0})")]
|
|
NotFound(String),
|
|
|
|
/// Target exists but belongs to a different app. v1.1.x rejects
|
|
/// cross-app invokes outright.
|
|
#[error("invoke: target script belongs to a different app")]
|
|
CrossApp,
|
|
|
|
/// Depth limit exceeded — `cx.trigger_depth >= limits.trigger_depth_max`.
|
|
/// Caller's `invoke()` would push the callee past the bound.
|
|
#[error("invoke: depth limit exceeded (max {0})")]
|
|
DepthExceeded(u32),
|
|
|
|
/// Caller principal lacks the required capability. Anonymous public-
|
|
/// HTTP scripts skip this check; authenticated callers gate.
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
/// Payload could not be serialized (e.g. a Rhai FnPtr passed across
|
|
/// the invoke boundary — closures are explicitly rejected).
|
|
#[error("invoke rejected: {0}")]
|
|
Rejected(String),
|
|
|
|
/// Backend / database error. Named `Backend` to align with KvError /
|
|
/// DocsError / FilesError / SecretsError / QueueError / PubsubError.
|
|
#[error("invoke backend error: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
/// Test-only stub: every call errors. Lets harnesses build a `Services`
|
|
/// bundle without wiring an `InvokeService`.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct NoopInvokeService;
|
|
|
|
#[async_trait]
|
|
impl InvokeService for NoopInvokeService {
|
|
async fn resolve(
|
|
&self,
|
|
_cx: &SdkCallCx,
|
|
_target: InvokeTarget,
|
|
) -> Result<ResolvedScript, InvokeError> {
|
|
Err(InvokeError::Backend("invoke is not wired in".into()))
|
|
}
|
|
|
|
async fn enqueue_async(
|
|
&self,
|
|
_cx: &SdkCallCx,
|
|
_target: InvokeTarget,
|
|
_args: serde_json::Value,
|
|
) -> Result<ExecutionId, InvokeError> {
|
|
Err(InvokeError::Backend("invoke is not wired in".into()))
|
|
}
|
|
}
|