diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index ee6b0ee..6e2e745 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -8,6 +8,7 @@ use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope}; use serde_json::Value as Json; use crate::sandbox::Limits; +use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic}; use crate::types::{ ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel, }; @@ -265,69 +266,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap, Ok((status_code, headers, body)) } -// ---------------------------------------------------------------------------- -// Rhai ↔ serde_json bridges -// ---------------------------------------------------------------------------- - -fn json_to_dynamic(value: Json) -> Dynamic { - match value { - Json::Null => Dynamic::UNIT, - Json::Bool(b) => b.into(), - Json::Number(n) => { - if let Some(i) = n.as_i64() { - i.into() - } else if let Some(f) = n.as_f64() { - f.into() - } else { - n.to_string().into() - } - } - Json::String(s) => s.into(), - Json::Array(arr) => arr - .into_iter() - .map(json_to_dynamic) - .collect::>() - .into(), - Json::Object(obj) => { - let mut m = Map::new(); - for (k, v) in obj { - m.insert(k.into(), json_to_dynamic(v)); - } - Dynamic::from(m) - } - } -} - -fn dynamic_to_json(value: &Dynamic) -> Json { - if value.is_unit() { - return Json::Null; - } - if let Ok(b) = value.as_bool() { - return Json::Bool(b); - } - if let Ok(i) = value.as_int() { - return Json::Number(i.into()); - } - if let Ok(f) = value.as_float() { - return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number); - } - if value.is_string() { - return Json::String(value.clone().into_string().unwrap_or_default()); - } - if let Some(arr) = value.clone().try_cast::() { - return Json::Array(arr.iter().map(dynamic_to_json).collect()); - } - if let Some(map) = value.clone().try_cast::() { - let mut out = serde_json::Map::new(); - for (k, v) in map { - out.insert(k.to_string(), dynamic_to_json(&v)); - } - return Json::Object(out); - } - // Anything else (timestamps, custom types) — best-effort string form. - Json::String(value.to_string()) -} - // ---------------------------------------------------------------------------- // Error mapping // ---------------------------------------------------------------------------- diff --git a/crates/executor-core/src/lib.rs b/crates/executor-core/src/lib.rs index 5e64a51..384a161 100644 --- a/crates/executor-core/src/lib.rs +++ b/crates/executor-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod context; pub mod engine; pub mod logging; pub mod sandbox; +pub mod sdk; pub mod types; pub use engine::Engine; diff --git a/crates/executor-core/src/sdk/bridge.rs b/crates/executor-core/src/sdk/bridge.rs new file mode 100644 index 0000000..07d223d --- /dev/null +++ b/crates/executor-core/src/sdk/bridge.rs @@ -0,0 +1,79 @@ +//! JSON ↔ Rhai `Dynamic` value bridge. +//! +//! Originally inline in `engine.rs`; moved here for v1.1.0 so future +//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert +//! values without `engine.rs` being the only owner of the conversions. +//! Behaviour is unchanged from the pre-extraction implementation — +//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the +//! observable round-trip. + +use rhai::{Dynamic, Map}; +use serde_json::Value as Json; + +/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for +/// pushing into a script's scope. Numbers prefer the narrowest type +/// (`i64` over `f64`); anything that can't round-trip falls back to a +/// string so the script always sees a defined value. +#[must_use] +pub fn json_to_dynamic(value: Json) -> Dynamic { + match value { + Json::Null => Dynamic::UNIT, + Json::Bool(b) => b.into(), + Json::Number(n) => { + if let Some(i) = n.as_i64() { + i.into() + } else if let Some(f) = n.as_f64() { + f.into() + } else { + n.to_string().into() + } + } + Json::String(s) => s.into(), + Json::Array(arr) => arr + .into_iter() + .map(json_to_dynamic) + .collect::>() + .into(), + Json::Object(obj) => { + let mut m = Map::new(); + for (k, v) in obj { + m.insert(k.into(), json_to_dynamic(v)); + } + Dynamic::from(m) + } + } +} + +/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai +/// types (timestamps, user-registered modules) fall back to their +/// `Display` form so they appear as strings in JSON output rather than +/// failing the response build. +#[must_use] +pub fn dynamic_to_json(value: &Dynamic) -> Json { + if value.is_unit() { + return Json::Null; + } + if let Ok(b) = value.as_bool() { + return Json::Bool(b); + } + if let Ok(i) = value.as_int() { + return Json::Number(i.into()); + } + if let Ok(f) = value.as_float() { + return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number); + } + if value.is_string() { + return Json::String(value.clone().into_string().unwrap_or_default()); + } + if let Some(arr) = value.clone().try_cast::() { + return Json::Array(arr.iter().map(dynamic_to_json).collect()); + } + if let Some(map) = value.clone().try_cast::() { + let mut out = serde_json::Map::new(); + for (k, v) in map { + out.insert(k.to_string(), dynamic_to_json(&v)); + } + return Json::Object(out); + } + Json::String(value.to_string()) +} diff --git a/crates/executor-core/src/sdk/cx.rs b/crates/executor-core/src/sdk/cx.rs new file mode 100644 index 0000000..5820041 --- /dev/null +++ b/crates/executor-core/src/sdk/cx.rs @@ -0,0 +1,10 @@ +//! Re-export of `picloud_shared::SdkCallCx`. +//! +//! The type itself lives in `picloud-shared` because future stateful +//! service impls live in `manager-core` (which `executor-core` must +//! not depend on) and need to reference the same cx shape. This +//! re-export lets executor-side code write +//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching +//! into `picloud_shared` for one type. + +pub use picloud_shared::SdkCallCx; diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs new file mode 100644 index 0000000..bbb478c --- /dev/null +++ b/crates/executor-core/src/sdk/mod.rs @@ -0,0 +1,39 @@ +//! SDK plumbing — types and the per-call registration entry point. +//! +//! `executor-core` is responsible for building the per-invocation Rhai +//! engine and wiring stateful services into it. v1.1.0 ships the +//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point) +//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1, +//! docs in v1.1.2, …) extend `register_all` rather than re-threading +//! plumbing through `engine.rs`. +//! +//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live +//! here so service modules can convert values without `engine.rs` +//! being the only home for the conversion logic. + +pub mod bridge; +pub mod cx; + +pub use bridge::{dynamic_to_json, json_to_dynamic}; +pub use cx::SdkCallCx; + +use std::sync::Arc; + +use picloud_shared::Services; +use rhai::Engine as RhaiEngine; + +/// Single hook every v1.1.x stateful service registers into. Called +/// once per invocation, just after `build_engine` constructs the +/// sandboxed Rhai engine and just before script compilation. +/// +/// v1.1.0 ships an intentionally empty body — the call site exists so +/// future PRs (KV first) drop their registration logic here rather +/// than reaching into `engine.rs::build_engine`. The signature is +/// locked: subsequent PRs MUST keep the same parameter shape so that +/// hosts don't have to re-thread the plumbing. +pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc) { + // Intentionally inert in v1.1.0. The unused-suppression below is a + // load-bearing placeholder: future PRs replace this `let _` with + // real `register_kv(engine, services, cx.clone())` calls etc. + let _ = (engine, services, cx); +}