From 36bf046791c5e32a3557bd0ff602b8d0d4a1cab4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 6 Jun 2026 19:44:43 +0200 Subject: [PATCH] feat(v1.1.9): InvokeService trait + InvokeTarget + ScriptRepository::get_by_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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) --- .../tests/module_redaction_logging.rs | 1 + crates/executor-core/tests/modules.rs | 1 + crates/executor-core/tests/sdk_docs.rs | 1 + crates/executor-core/tests/sdk_email.rs | 1 + crates/executor-core/tests/sdk_files.rs | 1 + crates/executor-core/tests/sdk_http.rs | 1 + crates/executor-core/tests/sdk_kv.rs | 1 + crates/executor-core/tests/sdk_pubsub.rs | 1 + crates/executor-core/tests/sdk_queue.rs | 1 + crates/executor-core/tests/sdk_secrets.rs | 1 + .../tests/sdk_subscriber_token.rs | 1 + crates/manager-core/src/repo.rs | 25 ++++ crates/manager-core/src/triggers_api.rs | 13 ++ crates/picloud/src/lib.rs | 13 ++ crates/shared/src/invoke.rs | 136 ++++++++++++++++++ crates/shared/src/lib.rs | 2 + crates/shared/src/services.rs | 18 ++- 17 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 crates/shared/src/invoke.rs diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs index 646f5ef..382fdd3 100644 --- a/crates/executor-core/tests/module_redaction_logging.rs +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -105,6 +105,7 @@ async fn original_backend_error_is_logged_at_error_level() { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); let engine = Engine::new(Limits::default(), services); diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index 3118950..59e277c 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -103,6 +103,7 @@ fn services_with(modules: Arc) -> Services { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ) } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index b99353f..f7d5aa2 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -234,6 +234,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs index 33bde64..a02e6ed 100644 --- a/crates/executor-core/tests/sdk_email.rs +++ b/crates/executor-core/tests/sdk_email.rs @@ -43,6 +43,7 @@ fn engine_with(rec: Arc) -> Arc { rec, Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index 9f35a79..6790ec0 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -171,6 +171,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index be4850d..ea5cce9 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -94,6 +94,7 @@ fn engine_with(http: Arc) -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index 458427d..968076f 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -113,6 +113,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index 7bb87c2..6057433 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -51,6 +51,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_queue.rs b/crates/executor-core/tests/sdk_queue.rs index b50193d..c8e503a 100644 --- a/crates/executor-core/tests/sdk_queue.rs +++ b/crates/executor-core/tests/sdk_queue.rs @@ -69,6 +69,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), svc, + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs index 5306106..dfec151 100644 --- a/crates/executor-core/tests/sdk_secrets.rs +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -104,6 +104,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index cf38c79..5b2fc72 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -98,6 +98,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), + Arc::new(picloud_shared::NoopInvokeService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/src/repo.rs b/crates/manager-core/src/repo.rs index b609389..5f577d9 100644 --- a/crates/manager-core/src/repo.rs +++ b/crates/manager-core/src/repo.rs @@ -24,6 +24,16 @@ pub enum ScriptRepositoryError { #[async_trait] pub trait ScriptRepository: Send + Sync { async fn get(&self, id: ScriptId) -> Result, ScriptRepositoryError>; + /// v1.1.9. Resolve `(app_id, name) → Script`. Backs `invoke("name", …)` + /// — the SDK lets a caller name a target script by its `scripts.name` + /// instead of plumbing UUIDs. Returns `None` if no script in the app + /// has that name. Names are unique per `(app_id, name)` already + /// (existing migration constraint). + async fn get_by_name( + &self, + app_id: AppId, + name: &str, + ) -> Result, ScriptRepositoryError>; /// Every script across all apps. Mostly for tests and admin /// "global" views; the dashboard reaches scripts via `list_for_app`. async fn list(&self) -> Result, ScriptRepositoryError>; @@ -145,6 +155,21 @@ impl ScriptRepository for PostgresScriptRepository { Ok(row.map(Into::into)) } + async fn get_by_name( + &self, + app_id: AppId, + name: &str, + ) -> Result, ScriptRepositoryError> { + let row = sqlx::query_as::<_, ScriptRow>(&format!( + "SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE app_id = $1 AND name = $2" + )) + .bind(app_id.into_inner()) + .bind(name) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(Into::into)) + } + async fn list(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, ScriptRow>(&format!( "SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name" diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index ae15c6e..2d2326a 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -1153,6 +1153,19 @@ mod tests { ) -> Result, crate::repo::ScriptRepositoryError> { Ok(self.existing.lock().await.get(&id).cloned()) } + async fn get_by_name( + &self, + app_id: AppId, + name: &str, + ) -> Result, crate::repo::ScriptRepositoryError> { + Ok(self + .existing + .lock() + .await + .values() + .find(|s| s.app_id == app_id && s.name == name) + .cloned()) + } async fn list( &self, ) -> Result, crate::repo::ScriptRepositoryError> { diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index ad701c5..91936b4 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -274,6 +274,11 @@ pub async fn build_app( authz.clone(), ), ); + // v1.1.9 function composition. Real InvokeService wired in commit 8; + // the noop here keeps the binary compiling — without it, scripts + // calling `invoke()` will throw a clear error. + let invoke: Arc = + Arc::new(picloud_shared::NoopInvokeService); let services = Services::new( kv, docs, @@ -287,6 +292,7 @@ pub async fn build_app( email, users.clone(), queue, + invoke, ); let engine = Arc::new(Engine::new(Limits::default(), services)); @@ -595,6 +601,13 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle { ) -> Result, picloud_manager_core::ScriptRepositoryError> { self.0.get(id).await } + async fn get_by_name( + &self, + app_id: picloud_shared::AppId, + name: &str, + ) -> Result, picloud_manager_core::ScriptRepositoryError> { + self.0.get_by_name(app_id, name).await + } async fn list( &self, ) -> Result, picloud_manager_core::ScriptRepositoryError> { diff --git a/crates/shared/src/invoke.rs b/crates/shared/src/invoke.rs new file mode 100644 index 0000000..78e9740 --- /dev/null +++ b/crates/shared/src/invoke.rs @@ -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`, 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, + /// 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; + + /// 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; +} + +#[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 { + Err(InvokeError::Unavailable("invoke is not wired in".into())) + } + + async fn enqueue_async( + &self, + _cx: &SdkCallCx, + _target: InvokeTarget, + _args: serde_json::Value, + ) -> Result { + Err(InvokeError::Unavailable("invoke is not wired in".into())) + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index c55db9a..11f399b 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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}; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 93f9e40..81971fb 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -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, + + /// 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, } impl Services { @@ -123,6 +128,7 @@ impl Services { email: Arc, users: Arc, queue: Arc, + invoke: Arc, ) -> 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), ) } }