feat(shared): SdkCallCx, Services bundle, ServiceEventEmitter trait shape

Foundation for the v1.1.x stateful SDK services. Lands the shape only:

  - SdkCallCx — per-call context plumbed into every future service
    trait method (app_id, principal, execution/request ids, trigger
    depth slots).
  - Services — empty non_exhaustive bundle; v1.1.1 (KV) adds the first
    field, subsequent PRs follow.
  - ServiceEventEmitter — async trait future services emit through;
    real outbox-backed impl lands with triggers in v1.1.1. NoopEventEmitter
    is the v1.1.0 default.

No behaviour change. Subsequent commits in this PR plumb these types
through executor-core and the orchestrator dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-30 18:40:09 +02:00
parent 662d5a2cf8
commit 2669714a51
4 changed files with 217 additions and 0 deletions

119
crates/shared/src/events.rs Normal file
View File

@@ -0,0 +1,119 @@
//! `ServiceEventEmitter` — the contract every stateful SDK service uses
//! to publish events into the (future) triggers framework.
//!
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
//! drops every event. The real outbox-backed implementation lands with
//! the triggers PR in v1.1.1; locking the trait now means services
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
//! re-thread their plumbing when the dispatcher arrives.
//!
//! Design rationale (full discussion: `docs/sdk-shape.md`):
//! * Async — outbox writes hit Postgres.
//! * Cx is passed in so the emitter can attribute the event to the
//! `app_id` / `principal` / `execution_id` that produced it.
//! * Events carry their semantic identity (`source` + `op`) plus
//! optional locator (`collection` + `key`) and optional payloads
//! (`payload` for the new value, `old_payload` for the previous on
//! updates). The dispatcher matches on (source, op, collection)
//! filters to decide which scripts to fan out to.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// Trait every stateful service depends on to emit events. The host
/// binary constructs one instance and clones the Arc into each service.
#[async_trait]
pub trait ServiceEventEmitter: Send + Sync {
/// Publish a single event. Implementations are expected to be
/// fire-and-forget from the caller's perspective: the outbox impl
/// will return `Ok(())` once the event is durably persisted, the
/// dispatcher reads it out-of-band.
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;
}
/// One service event. `source` and `op` are `&'static str` because they
/// come from a fixed enumeration baked into each service (`"kv"` +
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
/// `collection`/`key`/payloads come from user data and are owned.
#[derive(Debug, Clone)]
pub struct ServiceEvent {
/// Service namespace. Matches the Rhai module name: `"kv"`,
/// `"docs"`, `"files"`, etc.
pub source: &'static str,
/// Operation verb. Each service defines its own vocabulary;
/// dispatcher filters match on the literal string.
pub op: &'static str,
/// Affected collection, when the service is collection-scoped
/// (`kv`, `docs`, `files`). `None` for collection-less events.
pub collection: Option<String>,
/// Affected key/id within the collection, when applicable.
pub key: Option<String>,
/// New value after the operation, when carrying it is cheap and
/// useful. `None` for deletes.
pub payload: Option<serde_json::Value>,
/// Previous value before the operation, populated on `update` /
/// `delete` so triggers can diff. `None` on `insert`.
pub old_payload: Option<serde_json::Value>,
}
/// Errors an emitter can surface upward. The noop impl never returns
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
/// large for the outbox row).
#[derive(Debug, Error)]
pub enum EmitError {
#[error("event sink unavailable: {0}")]
Unavailable(String),
#[error("event sink rejected event: {0}")]
Rejected(String),
}
/// Default emitter for v1.1.0. Accepts every event, persists nothing,
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
/// triggers PR swaps this for a Postgres outbox writer.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEventEmitter;
#[async_trait]
impl ServiceEventEmitter for NoopEventEmitter {
async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Compile-time check that ServiceEventEmitter is dyn-safe — every
// service holds it as `Arc<dyn ServiceEventEmitter>` and would
// silently break the workspace if a non-object-safe method snuck
// in. Behavioural tests for the noop impl come for free once a
// service exercises it (v1.1.1+); avoid pulling tokio into
// `picloud-shared` just for a one-line `emit().await` check.
#[allow(dead_code)]
fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}
#[test]
fn service_event_construction_is_explicit() {
// Pin the field layout so a re-ordering in a future PR causes a
// compile failure here rather than silently misattributing
// events. Hash-derive isn't appropriate (serde_json::Value isn't
// Hash), so structural construction is the assertion.
let _ = ServiceEvent {
source: "kv",
op: "insert",
collection: Some("widgets".into()),
key: Some("k1".into()),
payload: Some(serde_json::json!({"v": 1})),
old_payload: None,
};
}
}

View File

@@ -7,23 +7,29 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error; pub mod error;
pub mod events;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
pub mod log_sink; pub mod log_sink;
pub mod route; pub mod route;
pub mod sandbox; pub mod sandbox;
pub mod script; pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod validator; pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape}; pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error; pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;
pub use script::Script; pub use script::Script;
pub use sdk_cx::SdkCallCx;
pub use services::Services;
pub use validator::{ScriptValidator, ValidationError}; pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -0,0 +1,54 @@
//! `SdkCallCx` — per-call context every stateful SDK service receives.
//!
//! Service trait methods (added by subsequent v1.1.x PRs starting with
//! KV) all take `&SdkCallCx` so they can:
//! * scope by `app_id` for cross-app isolation,
//! * audit `principal` when authenticated,
//! * carry `execution_id` / `request_id` into emitted events,
//! * bound trigger chains via `trigger_depth` / `root_execution_id`.
//!
//! The struct lives in `picloud-shared` (not `executor-core`) because
//! future service impls live in `manager-core` and the trait that hands
//! the cx in is shared by both sides. Pure value type — no handles, no
//! DB pool references, no allocations beyond what's in `Principal`.
use crate::{AppId, ExecutionId, Principal, RequestId};
/// Per-invocation context for every stateful SDK service call.
///
/// Constructed once at the start of an invocation by `executor-core`
/// from the incoming `ExecRequest`, then handed (by reference) to every
/// service trait method the script triggers during execution. Services
/// MUST derive `app_id` from this struct — never from script-passed
/// arguments — to preserve cross-app isolation.
#[derive(Debug, Clone)]
pub struct SdkCallCx {
/// Owning application for this invocation. Source of truth for
/// every `(app_id, …)` storage lookup the script makes.
pub app_id: AppId,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public endpoints);
/// `Some` when the call came in via the dashboard, an API key, or a
/// future authed surface.
pub principal: Option<Principal>,
/// Unique id for THIS execution. Matches `ExecRequest.execution_id`.
pub execution_id: ExecutionId,
/// Unique id for the ingress request that started the chain. The
/// same `request_id` is shared across every execution triggered by
/// the same request (direct + trigger fan-out).
pub request_id: RequestId,
/// `0` for direct invocations (HTTP request, manual run). Each
/// indirect invocation through the triggers framework (v1.1.1)
/// increments this; the dispatcher rejects beyond a configured
/// ceiling to prevent runaway feedback loops.
pub trigger_depth: u32,
/// `== execution_id` when `trigger_depth == 0`; otherwise the
/// `execution_id` of the original ingress execution. Lets the audit
/// log group every fan-out execution under the originating event.
pub root_execution_id: ExecutionId,
}

View File

@@ -0,0 +1,38 @@
//! `Services` — bundle of stateful SDK service handles plumbed from the
//! host binary into every Rhai execution.
//!
//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series
//! add one field per service:
//!
//! ```ignore
//! pub kv: Arc<dyn KvService>, // v1.1.1
//! pub docs: Arc<dyn DocsService>, // v1.1.2
//! pub http: Arc<dyn HttpService>, // v1.1.4
//! // …
//! ```
//!
//! The bundle is cheap to clone (`Arc` per service) and is constructed
//! once at startup in the picloud binary. The executor takes it by
//! reference per invocation, hands it (alongside an `SdkCallCx`) to
//! `executor-core::sdk::register_all`, which wires the corresponding
//! Rhai `::` namespace per service.
//!
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
//! consumers that only *pattern-match* a `&Services`; only crates that
//! *construct* a `Services` (in practice, just the picloud binary) need
//! to update their constructor when new services land.
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
#[derive(Default)]
pub struct Services {}
impl Services {
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
/// once the first service (KV, v1.1.1) lands.
#[must_use]
pub fn new() -> Self {
Self {}
}
}