Skip to main content

node_app_sdk_rust/
lib.rs

1//! # Node-App SDK for Rust
2//!
3//! Build native [Node-App] plugins as shared libraries (`cdylib`) with a
4//! safe, ergonomic Rust API. The SDK targets the **Node Host API v1**
5//! (the canonical C header is `core/host-abi-v1/include/node-host-api-v1.h`
6//! in the host repository).
7//!
8//! [Node-App]: https://github.com/econ-v1/node-app-distribution
9//!
10//! ## What this crate provides
11//!
12//! - The [`NodeApp`] trait — implement it on your plugin type to get HTTP,
13//!   event, and capability handling.
14//! - The [`declare_node_app!`] macro — generates all FFI boilerplate
15//!   (vtable, panic guards, JSON serialization) from your trait impl.
16//! - Helpers for talking to the host: [`log`], [`invoke_capability`],
17//!   [`publish_event`], [`get_config`], [`get_storage`], [`set_storage`].
18//! - Distributed-trace propagation across capability invocations
19//!   (thread-local span context — see [`CurrentTrace`]).
20//! - Hard limits matching the host: [`MAX_CAPABILITY_RESPONSE_SIZE`]
21//!   (16 MiB), [`MAX_EVENT_NAME_LEN`] (256 B), [`MAX_EVENT_DATA_LEN`]
22//!   (64 KiB).
23//!
24//! ## Stability
25//!
26//! This crate is published as **`1.0.1-experimental`**. The trait surface
27//! and the underlying C ABI are FROZEN for the v1 series, but breaking
28//! changes between `1.0.x-experimental` releases are possible until at
29//! least two first-party packages have proven the surface in production.
30//! Pin to an exact version in `Cargo.toml`.
31//!
32//! ## Quick start
33//!
34//! ```toml
35//! # Cargo.toml
36//! [lib]
37//! crate-type = ["cdylib"]
38//!
39//! [dependencies]
40//! node-app-sdk-rust = "1.0.1-experimental"
41//! serde_json = "1.0"
42//! ```
43//!
44//! ```rust,ignore
45//! use node_app_sdk_rust::*;
46//!
47//! #[derive(Default)]
48//! pub struct MyApp;
49//!
50//! impl NodeApp for MyApp {
51//!     fn metadata() -> NodeAppInfo {
52//!         NodeAppInfo {
53//!             name: "my-app".into(),
54//!             version: "0.1.0".into(),
55//!             author: "Me".into(),
56//!             description: "Hello-world Node-App".into(),
57//!             capabilities: vec!["http_handler".into()],
58//!         }
59//!     }
60//!
61//!     fn handle_request(&self, _req: AppRequest) -> Result<AppResponse, NodeAppError> {
62//!         Ok(AppResponse {
63//!             status: 200,
64//!             headers: Default::default(),
65//!             body: serde_json::json!({ "hello": "world" }),
66//!         })
67//!     }
68//! }
69//!
70//! declare_node_app!(MyApp);
71//! ```
72//!
73//! Build with `cargo build --release`; the resulting shared library plus a
74//! `manifest.json` is installed into the host's app directory.
75//!
76//! ## Calling host capabilities
77//!
78//! Use [`invoke_capability`] to call any capability registered with the
79//! host's capability router (e.g. `core.storage.get`,
80//! `core.lightning.payment.send`):
81//!
82//! ```rust,ignore
83//! use node_app_sdk_rust::{invoke_capability, CapabilityRequest};
84//!
85//! let response = invoke_capability(&CapabilityRequest {
86//!     id: "req-1".into(),
87//!     capability: "core.storage.get".into(),
88//!     payload: serde_json::json!({ "key": "user_pref" }),
89//!     caller_node_id: None,
90//!     trace_id: None,
91//!     span_id: None,
92//!     parent_span_id: None,
93//!     trace_depth: None,
94//! })?;
95//! # Ok::<(), node_app_sdk_rust::NodeAppError>(())
96//! ```
97//!
98//! Trace context (`trace_id`, `span_id`, `trace_depth`) is propagated
99//! automatically when invoking from inside `handle_capability` — you do
100//! not need to thread it manually.
101//!
102//! ## Publishing events
103//!
104//! ```rust,ignore
105//! use node_app_sdk_rust::publish_event;
106//!
107//! publish_event(
108//!     "my-app.something_happened",
109//!     &serde_json::json!({ "user_id": 42 }),
110//! )?;
111//! # Ok::<(), node_app_sdk_rust::NodeAppError>(())
112//! ```
113//!
114//! Event names **must** be namespaced with the app name (`my-app.*`); the
115//! host rejects un-namespaced events.
116
117#![warn(missing_docs)]
118#![warn(rustdoc::broken_intra_doc_links)]
119
120pub use node_app_api::types::{
121    AppEvent, AppRequest, AppResponse, Capabilities, CapabilityExample, CapabilityRequest,
122    CapabilityResponse, NodeAppInfo, ProvidedCapability,
123};
124pub use node_app_api::context::NodeAppContext;
125pub use node_app_api::ffi::{FfiResult, NodeAppMetadata, NodeAppVTable};
126pub use node_app_api::API_VERSION;
127
128use std::cell::RefCell;
129use std::ffi::CString;
130use std::sync::atomic::{AtomicPtr, Ordering};
131
132/// Maximum response size for capability handlers (16 MiB).
133///
134/// The host enforces this limit on every capability response. Apps that
135/// produce a larger response will see [`FfiResult::error`] returned with
136/// error code `-6`. Use streaming or pagination for larger payloads.
137pub const MAX_CAPABILITY_RESPONSE_SIZE: usize = 16 * 1024 * 1024;
138
139/// Trace fields for the currently-executing capability span (per-thread).
140///
141/// Set at the start of `handle_capability` by the [`declare_node_app!`]
142/// macro and cleared on return. Allows [`invoke_capability`] to propagate
143/// distributed trace context automatically without the caller having to
144/// thread `trace_id` / `span_id` through every call site.
145#[doc(hidden)]
146#[derive(Clone)]
147pub struct CurrentTrace {
148    /// Trace ID inherited from the inbound capability request.
149    pub trace_id: String,
150    /// Span ID of the current execution — becomes `parent_span_id` for
151    /// sub-invocations made from this thread.
152    pub span_id: String,
153    /// Depth of the current span in the trace tree (root = 0).
154    pub depth: u8,
155}
156
157thread_local! {
158    /// Current trace context for the executing capability (set by
159    /// [`declare_node_app!`]).
160    #[doc(hidden)]
161    pub static CURRENT_TRACE: RefCell<Option<CurrentTrace>> = const { RefCell::new(None) };
162}
163
164/// Global storage for the host context pointer.
165///
166/// Uses `AtomicPtr` instead of `OnceLock` so that the pointer can be
167/// **updated on every init call**. On macOS, `dlclose` does not actually
168/// unload user libraries (`man dlclose`: "Mac OS X does not support
169/// dynamic unloading"), so the same library instance is reused across
170/// hot-reloads. With a `OnceLock` the first-load context pointer would
171/// be retained permanently; after the first-load `HostData` is dropped
172/// that pointer becomes dangling, causing a SIGSEGV on the next reload
173/// when any SDK function (e.g. `log`) tries to read it.
174///
175/// `AtomicPtr` allows `__store_context` to atomically replace the pointer
176/// on each init, ensuring it always points to the current live `HostData`.
177///
178/// Safety invariant: the pointer is set to a valid `NodeAppContext` during
179/// `__node_app_init` and is only read while the app is alive. The host
180/// (NativeLoader) keeps `HostData` + `NodeAppContext` alive for the entire
181/// lifetime of the loaded app instance.
182static APP_CONTEXT: AtomicPtr<NodeAppContext> = AtomicPtr::new(std::ptr::null_mut());
183
184// ── Public api-store LLM SDK (feature 472) ──────────────────────────────────
185//
186// Type-safe surface for the host's `/api/v2/public/api_store/llm/{call,stream}`
187// endpoints. Lives in its own submodule so plugins that don't talk to the
188// public LLM endpoint never see these symbols in their import surface.
189pub mod llm;
190
191/// Log levels accepted by the [`log`] function.
192///
193/// Use these constants instead of magic numbers when calling
194/// [`log`] directly. The convenience macros ([`log_info!`], etc.)
195/// take care of this for you.
196pub mod log_level {
197    /// Most verbose level — use for fine-grained tracing.
198    pub const TRACE: u32 = 0;
199    /// Debug-level diagnostics, typically not shown in production.
200    pub const DEBUG: u32 = 1;
201    /// Informational messages indicating normal operation.
202    pub const INFO: u32 = 2;
203    /// Warnings — recoverable issues or unusual conditions.
204    pub const WARN: u32 = 3;
205    /// Errors — operations that failed and require attention.
206    pub const ERROR: u32 = 4;
207}
208
209/// Log a message to the host using the stored context.
210///
211/// Logs are written to the host's per-app log file
212/// (`{log_dir}/{app_name}.log`) and forwarded to the host's tracing
213/// subscriber, so they appear in the daemon's main log output too.
214///
215/// This function is a no-op if the context was not provided during init
216/// or if the message contains invalid UTF-8.
217///
218/// # Arguments
219/// * `level` - Log level (0=trace, 1=debug, 2=info, 3=warn, 4=error). Use the [`log_level`] constants.
220/// * `message` - The log message (must not contain interior NUL bytes).
221///
222/// # Example
223/// ```ignore
224/// use node_app_sdk_rust::{log, log_level};
225///
226/// log(log_level::INFO, "App initialized successfully");
227/// log(log_level::ERROR, "Something went wrong!");
228/// ```
229///
230/// Most callers should use the convenience macros ([`log_info!`],
231/// [`log_error!`], etc.) which accept `format!`-style arguments.
232pub fn log(level: u32, message: &str) {
233    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
234    if ctx_ptr.is_null() {
235        return;
236    }
237
238    let c_message = match CString::new(message) {
239        Ok(s) => s,
240        Err(_) => return, // Invalid message (contains null byte)
241    };
242
243    // Safety: ctx_ptr is valid for the app's lifetime, and host_log is a valid function pointer
244    unsafe {
245        let ctx = &*ctx_ptr;
246        (ctx.host_log)(ctx.host_data, level, c_message.as_ptr());
247    }
248}
249
250/// Invoke a capability on the host via the capability router.
251///
252/// This is the primary mechanism for app-to-app communication. The host
253/// resolves the capability name to the providing app, dispatches the
254/// request, and returns the response. The provider may itself be a
255/// different app, the host kernel, or a remote node (transparent to the
256/// caller).
257///
258/// Trace context is propagated automatically: if this call happens
259/// inside `handle_capability` and the inbound request carried a
260/// `trace_id`, the same trace ID is injected on outbound calls. Callers
261/// may override this by setting `trace_id` explicitly on the request.
262///
263/// # Errors
264///
265/// Returns [`NodeAppError::CapabilityError`] when:
266/// - The host context is not available (called before init).
267/// - The host's invoke callback is not wired up.
268/// - The capability is not registered, the provider rejects the call,
269///   or the response cannot be deserialized.
270pub fn invoke_capability(request: &CapabilityRequest) -> Result<CapabilityResponse, NodeAppError> {
271    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
272    if ctx_ptr.is_null() {
273        return Err(NodeAppError::CapabilityError(
274            "Host context not available".into(),
275        ));
276    }
277
278    // Propagate distributed trace context if a parent span is active on this thread.
279    // Only inject when the caller hasn't already set trace fields.
280    let effective_request: std::borrow::Cow<CapabilityRequest> =
281        if request.trace_id.is_none() {
282            CURRENT_TRACE.with(|tl| {
283                if let Some(trace) = tl.borrow().as_ref() {
284                    let injected = CapabilityRequest {
285                        trace_id: Some(trace.trace_id.clone()),
286                        span_id: Some(trace.span_id.clone()),
287                        parent_span_id: None,
288                        trace_depth: Some(trace.depth),
289                        ..request.clone()
290                    };
291                    std::borrow::Cow::Owned(injected)
292                } else {
293                    std::borrow::Cow::Borrowed(request)
294                }
295            })
296        } else {
297            std::borrow::Cow::Borrowed(request)
298        };
299
300    // Serialize the request to JSON
301    let request_json = serde_json::to_vec(effective_request.as_ref())?;
302
303    // Safety: ctx_ptr is valid for the app's lifetime
304    unsafe {
305        let ctx = &*ctx_ptr;
306
307        // Check if the callback is available
308        if ctx.host_invoke_capability as usize == 0 {
309            return Err(NodeAppError::CapabilityError(
310                "host_invoke_capability callback not available".into(),
311            ));
312        }
313
314        let result = (ctx.host_invoke_capability)(
315            ctx.host_data,
316            request_json.as_ptr(),
317            request_json.len(),
318        );
319
320        if result.success && !result.data.is_null() && result.data_len > 0 {
321            let response_slice = std::slice::from_raw_parts(result.data, result.data_len);
322            let response: CapabilityResponse = serde_json::from_slice(response_slice)
323                .map_err(|e| NodeAppError::CapabilityError(format!("Response deserialization error: {}", e)))?;
324            // Free the host-allocated data
325            // Note: The host is responsible for freeing this memory via its own allocator
326            Ok(response)
327        } else if !result.success {
328            Err(NodeAppError::CapabilityError(format!(
329                "Host capability invocation failed with error code {}",
330                result.error_code
331            )))
332        } else {
333            Err(NodeAppError::CapabilityError(
334                "Empty response from host".into(),
335            ))
336        }
337    }
338}
339
340/// Maximum event name length in bytes (256).
341///
342/// Names that exceed this limit are rejected by [`publish_event`] with
343/// [`NodeAppError::EventFailed`]. The host enforces the same limit on
344/// the receiving side.
345pub const MAX_EVENT_NAME_LEN: usize = 256;
346
347/// Maximum event data length in bytes (64 KiB).
348///
349/// Payloads that exceed this limit are rejected by [`publish_event`]
350/// with [`NodeAppError::EventFailed`]. For larger artifacts, store them
351/// (e.g. via `core.storage.insert`) and emit an event referencing the
352/// storage key instead.
353pub const MAX_EVENT_DATA_LEN: usize = 64 * 1024;
354
355/// Publish a domain event to the host event bus.
356///
357/// The event is queued asynchronously (fire-and-forget). The event
358/// name **must** be namespaced with the app name prefix
359/// (e.g. `lightning.payment_received`, `my-app.user_created`); the host
360/// rejects events whose name does not start with `{app_name}.`.
361///
362/// # Errors
363///
364/// Returns [`NodeAppError::EventFailed`] if the host context is not
365/// available, the event name or data exceeds size limits
366/// ([`MAX_EVENT_NAME_LEN`] / [`MAX_EVENT_DATA_LEN`]), or the host
367/// rejects the event.
368pub fn publish_event(name: &str, data: &serde_json::Value) -> Result<(), NodeAppError> {
369    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
370    if ctx_ptr.is_null() {
371        return Err(NodeAppError::EventFailed(
372            "Host context not available".into(),
373        ));
374    }
375
376    let name_bytes = name.as_bytes();
377    if name_bytes.len() > MAX_EVENT_NAME_LEN {
378        return Err(NodeAppError::EventFailed(format!(
379            "Event name exceeds {} byte limit (got {})",
380            MAX_EVENT_NAME_LEN,
381            name_bytes.len()
382        )));
383    }
384
385    let data_json = serde_json::to_vec(data)?;
386    if data_json.len() > MAX_EVENT_DATA_LEN {
387        return Err(NodeAppError::EventFailed(format!(
388            "Event data exceeds {} byte limit (got {})",
389            MAX_EVENT_DATA_LEN,
390            data_json.len()
391        )));
392    }
393
394    unsafe {
395        let ctx = &*ctx_ptr;
396
397        if ctx.host_publish_event as usize == 0 {
398            return Err(NodeAppError::EventFailed(
399                "host_publish_event callback not available".into(),
400            ));
401        }
402
403        let result = (ctx.host_publish_event)(
404            ctx.host_data,
405            name_bytes.as_ptr(),
406            name_bytes.len(),
407            data_json.as_ptr(),
408            data_json.len(),
409        );
410
411        if result == 0 {
412            Ok(())
413        } else {
414            Err(NodeAppError::EventFailed(format!(
415                "host_publish_event returned error code {}",
416                result
417            )))
418        }
419    }
420}
421
422/// Log a TRACE-level message using `format!`-style arguments.
423///
424/// No-op when the host context has not been wired up yet. See [`log`]
425/// for details about delivery semantics.
426#[macro_export]
427macro_rules! log_trace {
428    ($($arg:tt)*) => {
429        $crate::log($crate::log_level::TRACE, &format!($($arg)*))
430    };
431}
432
433/// Log a DEBUG-level message using `format!`-style arguments.
434#[macro_export]
435macro_rules! log_debug {
436    ($($arg:tt)*) => {
437        $crate::log($crate::log_level::DEBUG, &format!($($arg)*))
438    };
439}
440
441/// Log an INFO-level message using `format!`-style arguments.
442#[macro_export]
443macro_rules! log_info {
444    ($($arg:tt)*) => {
445        $crate::log($crate::log_level::INFO, &format!($($arg)*))
446    };
447}
448
449/// Log a WARN-level message using `format!`-style arguments.
450#[macro_export]
451macro_rules! log_warn {
452    ($($arg:tt)*) => {
453        $crate::log($crate::log_level::WARN, &format!($($arg)*))
454    };
455}
456
457/// Log an ERROR-level message using `format!`-style arguments.
458#[macro_export]
459macro_rules! log_error {
460    ($($arg:tt)*) => {
461        $crate::log($crate::log_level::ERROR, &format!($($arg)*))
462    };
463}
464
465/// Store the context pointer for use by host helper functions.
466///
467/// Called automatically by [`declare_node_app!`] during init. App code
468/// must not call this directly.
469///
470/// Uses `AtomicPtr::store` so the pointer is replaced on every init —
471/// required on macOS where `dlclose` never unloads user libraries and the
472/// same app instance is reused across hot-reloads.
473#[doc(hidden)]
474pub fn __store_context(ctx: *const NodeAppContext) {
475    APP_CONTEXT.store(ctx as *mut NodeAppContext, Ordering::Release);
476}
477
478/// Get a configuration value from the host by key.
479///
480/// Common keys include `data_dir`, `host_port`, `app_name`, and
481/// `app_id`. The full set of available keys is determined by the host
482/// at app-init time.
483///
484/// Returns `None` if the key is not registered or the context is not
485/// available (called before init).
486pub fn get_config(key: &str) -> Option<String> {
487    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
488    if ctx_ptr.is_null() {
489        return None;
490    }
491    let c_key = CString::new(key).ok()?;
492    unsafe {
493        let ctx = &*ctx_ptr;
494        let result = (ctx.host_get_config)(ctx.host_data, c_key.as_ptr());
495        if result.is_null() {
496            return None;
497        }
498        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
499    }
500}
501
502/// Set a storage value scoped to this app.
503///
504/// Storage is persisted by the host across app restarts and is isolated
505/// per-app: other apps cannot read or write keys you set here. Useful
506/// for small bits of configuration or state; for larger or relational
507/// data, prefer the `core.storage.*` capabilities.
508///
509/// No-op when the host context is not available or `key`/`value`
510/// contain interior NUL bytes.
511pub fn set_storage(key: &str, value: &str) {
512    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
513    if ctx_ptr.is_null() {
514        return;
515    }
516    let c_key = match CString::new(key) {
517        Ok(s) => s,
518        Err(_) => return,
519    };
520    let c_value = match CString::new(value) {
521        Ok(s) => s,
522        Err(_) => return,
523    };
524    unsafe {
525        let ctx = &*ctx_ptr;
526        (ctx.host_set_storage)(ctx.host_data, c_key.as_ptr(), c_value.as_ptr());
527    }
528}
529
530/// Get a storage value by key, scoped to this app.
531///
532/// See [`set_storage`] for scoping semantics. Returns `None` if the key
533/// is not found or the context is not available.
534pub fn get_storage(key: &str) -> Option<String> {
535    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
536    if ctx_ptr.is_null() {
537        return None;
538    }
539    let c_key = CString::new(key).ok()?;
540    unsafe {
541        let ctx = &*ctx_ptr;
542        let result = (ctx.host_get_storage)(ctx.host_data, c_key.as_ptr());
543        if result.is_null() {
544            return None;
545        }
546        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
547    }
548}
549
550/// Error type returned by app operations.
551///
552/// Variants surface specific failure modes from each lifecycle hook plus
553/// transport-level errors (serialization, capability dispatch). The
554/// `#[from] serde_json::Error` impl on [`NodeAppError::SerializationError`]
555/// allows the `?` operator to propagate JSON errors directly.
556#[derive(Debug, thiserror::Error)]
557pub enum NodeAppError {
558    /// Returned from [`NodeApp::init`] when initialization failed.
559    #[error("Initialization failed: {0}")]
560    InitFailed(String),
561    /// Returned from [`NodeApp::handle_request`] when handling failed.
562    #[error("Request handling failed: {0}")]
563    RequestFailed(String),
564    /// Returned from [`NodeApp::handle_event`] or [`publish_event`].
565    #[error("Event handling failed: {0}")]
566    EventFailed(String),
567    /// Returned from [`NodeApp::shutdown`] when graceful shutdown failed.
568    #[error("Shutdown failed: {0}")]
569    ShutdownFailed(String),
570    /// JSON (de)serialization error — auto-converted from
571    /// [`serde_json::Error`] via the `?` operator.
572    #[error("Serialization error: {0}")]
573    SerializationError(#[from] serde_json::Error),
574    /// Returned from [`NodeApp::handle_capability`] or [`invoke_capability`].
575    #[error("Capability error: {0}")]
576    CapabilityError(String),
577}
578
579/// Trait implemented by node-app plugins.
580///
581/// Pair this with [`declare_node_app!`] to generate all the FFI
582/// boilerplate. Implementors must be `Default + Send + Sync + 'static`
583/// because the macro stores the singleton instance behind a static
584/// `OnceLock<Mutex<T>>`.
585///
586/// All trait methods have sensible default implementations; override
587/// only the hooks the app uses. Apps that opt into a hook must also
588/// declare the matching capability in their manifest, e.g.
589/// `"http_handler"` for HTTP request routing.
590pub trait NodeApp: Default + Send + Sync + 'static {
591    /// Return app metadata (name, version, author, description, capabilities).
592    ///
593    /// Called once when the host loads the shared library. The values
594    /// are cached for the lifetime of the process.
595    fn metadata() -> NodeAppInfo;
596
597    /// Initialize the app with the host context.
598    ///
599    /// Called once after loading. The default impl is a no-op — override
600    /// to set up state (DB connections, caches, etc.). The context may
601    /// be `None` if the host has not wired up callbacks yet (very early
602    /// in bootstrap or in unit tests).
603    fn init(&mut self, _ctx: Option<&NodeAppContext>) -> Result<(), NodeAppError> {
604        Ok(())
605    }
606
607    /// Shut down the app gracefully.
608    ///
609    /// Called before unloading. Default is a no-op. Override to flush
610    /// pending writes, close connections, etc.
611    fn shutdown(&mut self) -> Result<(), NodeAppError> {
612        Ok(())
613    }
614
615    /// Handle an incoming HTTP request proxied from the host.
616    ///
617    /// Only invoked if the app declared the `http_handler` capability.
618    /// Default returns `501 Not Implemented`.
619    fn handle_request(&self, _request: AppRequest) -> Result<AppResponse, NodeAppError> {
620        Ok(AppResponse {
621            status: 501,
622            headers: Default::default(),
623            body: serde_json::json!({"error": "Not implemented"}),
624        })
625    }
626
627    /// Handle a domain event from the host event bus.
628    ///
629    /// Only invoked if the app declared the `event_listener` capability
630    /// and subscribed to the event's namespace via the manifest.
631    fn handle_event(&self, _event: AppEvent) -> Result<(), NodeAppError> {
632        Ok(())
633    }
634
635    /// Return the list of service capabilities this app provides.
636    ///
637    /// Override to declare capabilities for the host's capability
638    /// registry. Default returns an empty list (no capabilities
639    /// provided). Each entry must use the namespace `{app_name}.{domain}.{action}`
640    /// — the `core.*` namespace is reserved for first-party apps.
641    fn provided_capabilities() -> Vec<ProvidedCapability> {
642        Vec::new()
643    }
644
645    /// Handle a capability invocation from another app via the
646    /// capability router.
647    ///
648    /// Override to implement capability handling logic. The default
649    /// returns [`NodeAppError::CapabilityError`] indicating the
650    /// capability is not implemented.
651    ///
652    /// Trace context is automatically captured into thread-local state
653    /// for the duration of this call so [`invoke_capability`] can
654    /// propagate it on outbound calls without explicit threading.
655    fn handle_capability(
656        &self,
657        _request: CapabilityRequest,
658    ) -> Result<CapabilityResponse, NodeAppError> {
659        Err(NodeAppError::CapabilityError(
660            "Capability handling not implemented".into(),
661        ))
662    }
663}
664
665/// Generate all FFI boilerplate for a [`NodeApp`] implementation.
666///
667/// This macro creates:
668/// - A `OnceLock<Mutex<T>>` instance for the app (sound concurrent access).
669/// - The `_node_app_entry` export symbol returning a [`NodeAppVTable`].
670/// - FFI wrapper functions for `init`, `shutdown`, `handle_request`,
671///   `handle_event`, `handle_capability`, and `free`.
672/// - `catch_unwind` guards on every FFI boundary to prevent UB from
673///   panics crossing the Rust/C ABI.
674/// - Thread-local trace span management around `handle_capability`.
675///
676/// Invoke once at the crate root after defining the app type:
677///
678/// ```ignore
679/// declare_node_app!(MyApp);
680/// ```
681#[macro_export]
682macro_rules! declare_node_app {
683    ($app_type:ty) => {
684        // Feature 463 B5 fix — RwLock instead of Mutex so capability calls
685        // can run CONCURRENTLY (read-locked). The previous Mutex serialized
686        // every call, causing a deadlock when one capability (e.g. agent.prompt)
687        // invokes another capability on the SAME app re-entrantly through the
688        // graph engine (agent.prompt → graph.engine.send_event → graph node →
689        // graph.tasks.storage.* → core.agent_storage.* → BACK to agent app).
690        // init/shutdown still acquire write-lock so they remain exclusive.
691        static APP_INSTANCE: std::sync::OnceLock<std::sync::RwLock<$app_type>> =
692            std::sync::OnceLock::new();
693        static VTABLE: std::sync::OnceLock<$crate::NodeAppVTable> =
694            std::sync::OnceLock::new();
695
696        // OnceLock-backed CStrings for metadata (valid for process lifetime)
697        static META_NAME: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
698        static META_VERSION: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
699        static META_AUTHOR: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
700        static META_DESCRIPTION: std::sync::OnceLock<std::ffi::CString> =
701            std::sync::OnceLock::new();
702
703        unsafe extern "C" fn __node_app_init(
704            ctx: *const std::os::raw::c_void,
705        ) -> $crate::FfiResult {
706            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
707                let ctx_opt = if ctx.is_null() {
708                    None
709                } else {
710                    // Store context pointer for use by log() helper
711                    let ctx_typed = ctx as *const $crate::NodeAppContext;
712                    $crate::__store_context(ctx_typed);
713                    Some(unsafe { &*ctx_typed })
714                };
715                let app = APP_INSTANCE.get_or_init(|| {
716                    std::sync::RwLock::new(<$app_type>::default())
717                });
718                let mut guard = match app.write() {
719                    Ok(g) => g,
720                    Err(e) => {
721                        eprintln!("[node-app] rwlock poisoned in init: {}", e);
722                        return $crate::FfiResult::error(-10);
723                    }
724                };
725                match guard.init(ctx_opt) {
726                    Ok(()) => $crate::FfiResult::ok(),
727                    Err(e) => {
728                        let msg = format!("init error: {}", e);
729                        $crate::log($crate::log_level::ERROR, &msg);
730                        eprintln!("[node-app] {}", msg);
731                        $crate::FfiResult::error(-1)
732                    }
733                }
734            })) {
735                Ok(result) => result,
736                Err(_) => {
737                    eprintln!("[node-app] panic in init");
738                    $crate::FfiResult::error(-99)
739                }
740            }
741        }
742
743        unsafe extern "C" fn __node_app_shutdown() -> $crate::FfiResult {
744            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
745                if let Some(app) = APP_INSTANCE.get() {
746                    let mut guard = match app.write() {
747                        Ok(g) => g,
748                        Err(e) => {
749                            eprintln!("[node-app] rwlock poisoned in shutdown: {}", e);
750                            return $crate::FfiResult::error(-10);
751                        }
752                    };
753                    match guard.shutdown() {
754                        Ok(()) => $crate::FfiResult::ok(),
755                        Err(e) => {
756                            let msg = format!("shutdown error: {}", e);
757                            $crate::log($crate::log_level::ERROR, &msg);
758                            eprintln!("[node-app] {}", msg);
759                            $crate::FfiResult::error(-1)
760                        }
761                    }
762                } else {
763                    $crate::FfiResult::ok()
764                }
765            })) {
766                Ok(result) => result,
767                Err(_) => {
768                    eprintln!("[node-app] panic in shutdown");
769                    $crate::FfiResult::error(-99)
770                }
771            }
772        }
773
774        unsafe extern "C" fn __node_app_handle_request(
775            request_json: *const u8,
776            request_len: usize,
777        ) -> $crate::FfiResult {
778            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
779                let json_slice =
780                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
781                let request: $crate::AppRequest = match serde_json::from_slice(json_slice) {
782                    Ok(r) => r,
783                    Err(e) => {
784                        eprintln!("[node-app] request deserialization error: {}", e);
785                        return $crate::FfiResult::error(-2);
786                    }
787                };
788
789                let app = match APP_INSTANCE.get() {
790                    Some(a) => a,
791                    None => return $crate::FfiResult::error(-3),
792                };
793                // Feature 463 B5 — read-lock allows concurrent calls
794                let guard = match app.read() {
795                    Ok(g) => g,
796                    Err(e) => {
797                        eprintln!("[node-app] rwlock poisoned in handle_request: {}", e);
798                        return $crate::FfiResult::error(-10);
799                    }
800                };
801
802                match guard.handle_request(request) {
803                    Ok(response) => match serde_json::to_vec(&response) {
804                        Ok(bytes) => {
805                            let len = bytes.len();
806                            let boxed = bytes.into_boxed_slice();
807                            let ptr = Box::into_raw(boxed) as *mut u8;
808                            $crate::FfiResult {
809                                success: true,
810                                error_code: 0,
811                                data: ptr,
812                                data_len: len,
813                            }
814                        }
815                        Err(e) => {
816                            eprintln!("[node-app] response serialization error: {}", e);
817                            $crate::FfiResult::error(-4)
818                        }
819                    },
820                    Err(e) => {
821                        eprintln!("[node-app] handle_request error: {}", e);
822                        $crate::FfiResult::error(-5)
823                    }
824                }
825            })) {
826                Ok(result) => result,
827                Err(_) => {
828                    eprintln!("[node-app] panic in handle_request");
829                    $crate::FfiResult::error(-99)
830                }
831            }
832        }
833
834        unsafe extern "C" fn __node_app_handle_event(
835            event_json: *const u8,
836            event_len: usize,
837        ) -> $crate::FfiResult {
838            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
839                let json_slice =
840                    unsafe { std::slice::from_raw_parts(event_json, event_len) };
841                let event: $crate::AppEvent = match serde_json::from_slice(json_slice) {
842                    Ok(e) => e,
843                    Err(e) => {
844                        eprintln!("[node-app] event deserialization error: {}", e);
845                        return $crate::FfiResult::error(-2);
846                    }
847                };
848
849                let app = match APP_INSTANCE.get() {
850                    Some(a) => a,
851                    None => return $crate::FfiResult::error(-3),
852                };
853                // Feature 463 B5 — read-lock allows concurrent calls
854                let guard = match app.read() {
855                    Ok(g) => g,
856                    Err(e) => {
857                        eprintln!("[node-app] rwlock poisoned in handle_event: {}", e);
858                        return $crate::FfiResult::error(-10);
859                    }
860                };
861
862                match guard.handle_event(event) {
863                    Ok(()) => $crate::FfiResult::ok(),
864                    Err(e) => {
865                        eprintln!("[node-app] handle_event error: {}", e);
866                        $crate::FfiResult::error(-5)
867                    }
868                }
869            })) {
870                Ok(result) => result,
871                Err(_) => {
872                    eprintln!("[node-app] panic in handle_event");
873                    $crate::FfiResult::error(-99)
874                }
875            }
876        }
877
878        unsafe extern "C" fn __node_app_handle_capability(
879            request_json: *const u8,
880            request_len: usize,
881        ) -> $crate::FfiResult {
882            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
883                let json_slice =
884                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
885                let request: $crate::CapabilityRequest = match serde_json::from_slice(json_slice) {
886                    Ok(r) => r,
887                    Err(e) => {
888                        eprintln!("[node-app] capability request deserialization error: {}", e);
889                        return $crate::FfiResult::error(-2);
890                    }
891                };
892
893                let app = match APP_INSTANCE.get() {
894                    Some(a) => a,
895                    None => return $crate::FfiResult::error(-3),
896                };
897                // Feature 463 B5 — read-lock allows the SAME app to handle
898                // re-entrant capability calls (e.g. agent.prompt outer call
899                // and an inner core.agent_storage.write from a graph node)
900                // concurrently. handle_capability is &self so this is sound.
901                let guard = match app.read() {
902                    Ok(g) => g,
903                    Err(e) => {
904                        eprintln!("[node-app] rwlock poisoned in handle_capability: {}", e);
905                        return $crate::FfiResult::error(-10);
906                    }
907                };
908
909                // Set thread-local trace context for duration of this capability call.
910                // This allows invoke_capability() to propagate trace automatically.
911                $crate::CURRENT_TRACE.with(|tl| {
912                    *tl.borrow_mut() = if let Some(ref trace_id) = request.trace_id {
913                        Some($crate::CurrentTrace {
914                            trace_id: trace_id.clone(),
915                            span_id: request.span_id.clone().unwrap_or_default(),
916                            depth: request.trace_depth.unwrap_or(0),
917                        })
918                    } else {
919                        None
920                    };
921                });
922
923                let cap_result = guard.handle_capability(request);
924
925                // Clear trace context after call completes.
926                $crate::CURRENT_TRACE.with(|tl| {
927                    *tl.borrow_mut() = None;
928                });
929
930                match cap_result {
931                    Ok(response) => match serde_json::to_vec(&response) {
932                        Ok(bytes) => {
933                            // Enforce 16MB response limit
934                            if bytes.len() > $crate::MAX_CAPABILITY_RESPONSE_SIZE {
935                                eprintln!(
936                                    "[node-app] capability response exceeds 16MB limit ({} bytes)",
937                                    bytes.len()
938                                );
939                                return $crate::FfiResult::error(-6);
940                            }
941                            let len = bytes.len();
942                            let boxed = bytes.into_boxed_slice();
943                            let ptr = Box::into_raw(boxed) as *mut u8;
944                            $crate::FfiResult {
945                                success: true,
946                                error_code: 0,
947                                data: ptr,
948                                data_len: len,
949                            }
950                        }
951                        Err(e) => {
952                            eprintln!("[node-app] capability response serialization error: {}", e);
953                            $crate::FfiResult::error(-4)
954                        }
955                    },
956                    Err(e) => {
957                        eprintln!("[node-app] handle_capability error: {}", e);
958                        $crate::FfiResult::error(-5)
959                    }
960                }
961            })) {
962                Ok(result) => result,
963                Err(_) => {
964                    eprintln!("[node-app] panic in handle_capability");
965                    $crate::FfiResult::error(-99)
966                }
967            }
968        }
969
970        unsafe extern "C" fn __node_app_free(ptr: *mut u8, len: usize) {
971            if !ptr.is_null() && len > 0 {
972                let _ = unsafe { Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)) };
973            }
974        }
975
976        #[no_mangle]
977        pub unsafe extern "C" fn _node_app_entry(
978            _ctx: *const std::os::raw::c_void,
979        ) -> *const $crate::NodeAppVTable {
980            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
981                let info = <$app_type as $crate::NodeApp>::metadata();
982                let caps = info.capability_flags();
983
984                let name = META_NAME
985                    .get_or_init(|| std::ffi::CString::new(info.name).unwrap_or_default());
986                let version = META_VERSION
987                    .get_or_init(|| std::ffi::CString::new(info.version).unwrap_or_default());
988                let author = META_AUTHOR
989                    .get_or_init(|| std::ffi::CString::new(info.author).unwrap_or_default());
990                let description = META_DESCRIPTION
991                    .get_or_init(|| std::ffi::CString::new(info.description).unwrap_or_default());
992
993                let metadata = $crate::NodeAppMetadata {
994                    api_version: $crate::API_VERSION,
995                    name: name.as_ptr(),
996                    version: version.as_ptr(),
997                    author: author.as_ptr(),
998                    description: description.as_ptr(),
999                    capabilities: caps.bits(),
1000                };
1001
1002                VTABLE.get_or_init(|| $crate::NodeAppVTable {
1003                    metadata,
1004                    init: __node_app_init,
1005                    shutdown: __node_app_shutdown,
1006                    handle_request: __node_app_handle_request,
1007                    handle_event: __node_app_handle_event,
1008                    handle_capability: __node_app_handle_capability,
1009                    free: __node_app_free,
1010                }) as *const $crate::NodeAppVTable
1011            })) {
1012                Ok(ptr) => ptr,
1013                Err(_) => {
1014                    eprintln!("[node-app] panic in _node_app_entry");
1015                    std::ptr::null()
1016                }
1017            }
1018        }
1019    };
1020}