feat(v1.1.9): InvokeService trait + InvokeTarget + ScriptRepository::get_by_name

shared/invoke.rs introduces the InvokeService trait + InvokeTarget enum:

  - InvokeTarget::Path(String)  — resolves via the route trie
  - InvokeTarget::Name(String)  — (cx.app_id, name) -> script_id via get_by_name
  - InvokeTarget::Id(ScriptId)  — direct lookup

InvokeService::resolve enforces same-app at the service layer
(InvokeError::CrossApp). InvokeService::enqueue_async writes a v1.1.9
OutboxSourceKind::Invoke row for fire-and-forget composition.

Errors: NotFound, CrossApp, DepthExceeded(u32), Forbidden, Rejected,
Unavailable. NoopInvokeService surfaces all calls as Unavailable for
harnesses that don't wire the service.

ScriptRepository gains get_by_name(app_id, name) backed by the existing
(app_id, name) uniqueness constraint. Postgres impl + in-memory mock +
the picloud crate's PostgresScriptRepoHandle delegate added.

Services::new gains invoke: Arc<dyn InvokeService> positionally after
queue. with_noop_services wires NoopInvokeService. 12 test sites
threaded through. picloud/lib.rs binds NoopInvokeService at this commit
boundary — the real InvokeResolver lands in commit 8.

Deviation from plan (flagged for HANDBACK §7): the plan called for
moving ExecutorClient + ScriptIdentity from orchestrator-core to shared
so the invoke bridge could call a re-entrant variant. Re-evaluated:
the bridge lives in executor-core which can hold Arc<Engine> directly,
making the trait move unnecessary. Engine::execute is sync, returns
ExecResponse, and shares the engine instance + per-call SdkCallCx
exactly as required. AST cache reuse across invokes is a v1.2 optimization
(invokes recompile each call — ms-scale cost, acceptable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 19:44:43 +02:00
parent 6891eda66c
commit 36bf046791
17 changed files with 213 additions and 5 deletions

136
crates/shared/src/invoke.rs Normal file
View File

@@ -0,0 +1,136 @@
//! `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, 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,
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.
#[error("invoke backend error: {0}")]
Unavailable(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::Unavailable("invoke is not wired in".into()))
}
async fn enqueue_async(
&self,
_cx: &SdkCallCx,
_target: InvokeTarget,
_args: serde_json::Value,
) -> Result<ExecutionId, InvokeError> {
Err(InvokeError::Unavailable("invoke is not wired in".into()))
}
}

View File

@@ -18,6 +18,7 @@ pub mod files;
pub mod http;
pub mod ids;
pub mod inbox;
pub mod invoke;
pub mod kv;
pub mod log_sink;
pub mod modules;
@@ -60,6 +61,7 @@ pub use ids::{
pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
};
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};

View File

@@ -20,11 +20,11 @@
use std::sync::Arc;
use crate::{
DeadLetterService, DocsService, EmailService, FilesService, HttpService, KvService,
ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService,
NoopQueueService, NoopSecretsService, NoopUsersService, PubsubService, QueueService,
SecretsService, ServiceEventEmitter, UsersService,
DeadLetterService, DocsService, EmailService, FilesService, HttpService, InvokeService,
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -102,6 +102,11 @@ pub struct Services {
/// touch queues. Consumers register via `queue:receive` triggers
/// (one per `(app_id, queue_name)`).
pub queue: Arc<dyn QueueService>,
/// Same-app function composition (v1.1.9). Backs `invoke()` (sync)
/// and `invoke_async()` (fire-and-forget through the outbox).
/// Cross-app invokes are rejected at the service entry point.
pub invoke: Arc<dyn InvokeService>,
}
impl Services {
@@ -123,6 +128,7 @@ impl Services {
email: Arc<dyn EmailService>,
users: Arc<dyn UsersService>,
queue: Arc<dyn QueueService>,
invoke: Arc<dyn InvokeService>,
) -> Self {
Self {
kv,
@@ -137,6 +143,7 @@ impl Services {
email,
users,
queue,
invoke,
}
}
@@ -160,6 +167,7 @@ impl Services {
Arc::new(NoopEmailService),
Arc::new(NoopUsersService),
Arc::new(NoopQueueService),
Arc::new(NoopInvokeService),
)
}
}