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    #[doc(hidden)]
163    pub static CURRENT_INVOCATION_CONTEXT: RefCell<Option<String>> = const { RefCell::new(None) };
164}
165
166#[doc(hidden)]
167pub struct CurrentInvocationContextGuard(Option<String>);
168
169impl CurrentInvocationContextGuard {
170    #[doc(hidden)]
171    pub fn enter(invocation_context_id: Option<String>) -> Self {
172        let previous = CURRENT_INVOCATION_CONTEXT.with(|current| {
173            std::mem::replace(&mut *current.borrow_mut(), invocation_context_id)
174        });
175        Self(previous)
176    }
177}
178
179impl Drop for CurrentInvocationContextGuard {
180    fn drop(&mut self) {
181        let previous = self.0.take();
182        CURRENT_INVOCATION_CONTEXT.with(|current| {
183            *current.borrow_mut() = previous;
184        });
185    }
186}
187
188/// Global storage for the host context pointer.
189///
190/// Uses `AtomicPtr` instead of `OnceLock` so that the pointer can be
191/// **updated on every init call**. On macOS, `dlclose` does not actually
192/// unload user libraries (`man dlclose`: "Mac OS X does not support
193/// dynamic unloading"), so the same library instance is reused across
194/// hot-reloads. With a `OnceLock` the first-load context pointer would
195/// be retained permanently; after the first-load `HostData` is dropped
196/// that pointer becomes dangling, causing a SIGSEGV on the next reload
197/// when any SDK function (e.g. `log`) tries to read it.
198///
199/// `AtomicPtr` allows `__store_context` to atomically replace the pointer
200/// on each init, ensuring it always points to the current live `HostData`.
201///
202/// Safety invariant: the pointer is set to a valid `NodeAppContext` during
203/// `__node_app_init` and is only read while the app is alive. The host
204/// (NativeLoader) keeps `HostData` + `NodeAppContext` alive for the entire
205/// lifetime of the loaded app instance.
206static APP_CONTEXT: AtomicPtr<NodeAppContext> = AtomicPtr::new(std::ptr::null_mut());
207
208// ── Public api-store LLM SDK (feature 472) ──────────────────────────────────
209//
210// Type-safe surface for the host's `/api/v2/public/api_store/llm/{call,stream}`
211// endpoints. Lives in its own submodule so plugins that don't talk to the
212// public LLM endpoint never see these symbols in their import surface.
213pub mod llm;
214
215/// Log levels accepted by the [`log`] function.
216///
217/// Use these constants instead of magic numbers when calling
218/// [`log`] directly. The convenience macros ([`log_info!`], etc.)
219/// take care of this for you.
220pub mod log_level {
221    /// Most verbose level — use for fine-grained tracing.
222    pub const TRACE: u32 = 0;
223    /// Debug-level diagnostics, typically not shown in production.
224    pub const DEBUG: u32 = 1;
225    /// Informational messages indicating normal operation.
226    pub const INFO: u32 = 2;
227    /// Warnings — recoverable issues or unusual conditions.
228    pub const WARN: u32 = 3;
229    /// Errors — operations that failed and require attention.
230    pub const ERROR: u32 = 4;
231}
232
233/// Log a message to the host using the stored context.
234///
235/// Logs are written to the host's per-app log file
236/// (`{log_dir}/{app_name}.log`) and forwarded to the host's tracing
237/// subscriber, so they appear in the daemon's main log output too.
238///
239/// This function is a no-op if the context was not provided during init
240/// or if the message contains invalid UTF-8.
241///
242/// # Arguments
243/// * `level` - Log level (0=trace, 1=debug, 2=info, 3=warn, 4=error). Use the [`log_level`] constants.
244/// * `message` - The log message (must not contain interior NUL bytes).
245///
246/// # Example
247/// ```ignore
248/// use node_app_sdk_rust::{log, log_level};
249///
250/// log(log_level::INFO, "App initialized successfully");
251/// log(log_level::ERROR, "Something went wrong!");
252/// ```
253///
254/// Most callers should use the convenience macros ([`log_info!`],
255/// [`log_error!`], etc.) which accept `format!`-style arguments.
256pub fn log(level: u32, message: &str) {
257    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
258    if ctx_ptr.is_null() {
259        return;
260    }
261
262    let c_message = match CString::new(message) {
263        Ok(s) => s,
264        Err(_) => return, // Invalid message (contains null byte)
265    };
266
267    // Safety: ctx_ptr is valid for the app's lifetime, and host_log is a valid function pointer
268    unsafe {
269        let ctx = &*ctx_ptr;
270        (ctx.host_log)(ctx.host_data, level, c_message.as_ptr());
271    }
272}
273
274/// Invoke a capability on the host via the capability router.
275///
276/// This is the primary mechanism for app-to-app communication. The host
277/// resolves the capability name to the providing app, dispatches the
278/// request, and returns the response. The provider may itself be a
279/// different app, the host kernel, or a remote node (transparent to the
280/// caller).
281///
282/// Trace context is propagated automatically: if this call happens
283/// inside `handle_capability` and the inbound request carried a
284/// `trace_id`, the same trace ID is injected on outbound calls. Callers
285/// may override this by setting `trace_id` explicitly on the request.
286///
287/// # Errors
288///
289/// Returns [`NodeAppError::CapabilityError`] when:
290/// - The host context is not available (called before init).
291/// - The host's invoke callback is not wired up.
292/// - The capability is not registered, the provider rejects the call,
293///   or the response cannot be deserialized.
294pub fn invoke_capability(request: &CapabilityRequest) -> Result<CapabilityResponse, NodeAppError> {
295    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
296    if ctx_ptr.is_null() {
297        return Err(NodeAppError::CapabilityError(
298            "Host context not available".into(),
299        ));
300    }
301
302    // Propagate distributed trace context if a parent span is active on this thread.
303    // Only inject when the caller hasn't already set trace fields.
304    let active_invocation_context =
305        CURRENT_INVOCATION_CONTEXT.with(|current| current.borrow().clone());
306    let active_trace = if request.trace_id.is_none() {
307        CURRENT_TRACE.with(|current| current.borrow().clone())
308    } else {
309        None
310    };
311    let effective_request: std::borrow::Cow<CapabilityRequest> =
312        if active_trace.is_some() || active_invocation_context.is_some() {
313            let mut injected = request.clone();
314            if let Some(trace) = active_trace {
315                injected.trace_id = Some(trace.trace_id);
316                injected.span_id = Some(trace.span_id);
317                injected.parent_span_id = None;
318                injected.trace_depth = Some(trace.depth);
319            }
320            if let Some(context_id) = active_invocation_context {
321                injected.invocation_context_id = Some(context_id);
322            }
323            std::borrow::Cow::Owned(injected)
324        } else {
325            std::borrow::Cow::Borrowed(request)
326        };
327
328    // Serialize the request to JSON
329    let request_json = serde_json::to_vec(effective_request.as_ref())?;
330
331    // Safety: ctx_ptr is valid for the app's lifetime
332    unsafe {
333        let ctx = &*ctx_ptr;
334
335        // Check if the callback is available
336        if ctx.host_invoke_capability as usize == 0 {
337            return Err(NodeAppError::CapabilityError(
338                "host_invoke_capability callback not available".into(),
339            ));
340        }
341
342        let result = (ctx.host_invoke_capability)(
343            ctx.host_data,
344            request_json.as_ptr(),
345            request_json.len(),
346        );
347
348        if result.success && !result.data.is_null() && result.data_len > 0 {
349            let response_slice = std::slice::from_raw_parts(result.data, result.data_len);
350            let response: CapabilityResponse = serde_json::from_slice(response_slice)
351                .map_err(|e| NodeAppError::CapabilityError(format!("Response deserialization error: {}", e)))?;
352            // Free the host-allocated data
353            // Note: The host is responsible for freeing this memory via its own allocator
354            Ok(response)
355        } else if !result.success {
356            Err(NodeAppError::CapabilityError(format!(
357                "Host capability invocation failed with error code {}",
358                result.error_code
359            )))
360        } else {
361            Err(NodeAppError::CapabilityError(
362                "Empty response from host".into(),
363            ))
364        }
365    }
366}
367
368/// Maximum event name length in bytes (256).
369///
370/// Names that exceed this limit are rejected by [`publish_event`] with
371/// [`NodeAppError::EventFailed`]. The host enforces the same limit on
372/// the receiving side.
373pub const MAX_EVENT_NAME_LEN: usize = 256;
374
375/// Maximum event data length in bytes (64 KiB).
376///
377/// Payloads that exceed this limit are rejected by [`publish_event`]
378/// with [`NodeAppError::EventFailed`]. For larger artifacts, store them
379/// (e.g. via `core.storage.insert`) and emit an event referencing the
380/// storage key instead.
381pub const MAX_EVENT_DATA_LEN: usize = 64 * 1024;
382
383/// Publish a domain event to the host event bus.
384///
385/// The event is queued asynchronously (fire-and-forget). The event
386/// name **must** be namespaced with the app name prefix
387/// (e.g. `lightning.payment_received`, `my-app.user_created`); the host
388/// rejects events whose name does not start with `{app_name}.`.
389///
390/// # Errors
391///
392/// Returns [`NodeAppError::EventFailed`] if the host context is not
393/// available, the event name or data exceeds size limits
394/// ([`MAX_EVENT_NAME_LEN`] / [`MAX_EVENT_DATA_LEN`]), or the host
395/// rejects the event.
396pub fn publish_event(name: &str, data: &serde_json::Value) -> Result<(), NodeAppError> {
397    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
398    if ctx_ptr.is_null() {
399        return Err(NodeAppError::EventFailed(
400            "Host context not available".into(),
401        ));
402    }
403
404    let name_bytes = name.as_bytes();
405    if name_bytes.len() > MAX_EVENT_NAME_LEN {
406        return Err(NodeAppError::EventFailed(format!(
407            "Event name exceeds {} byte limit (got {})",
408            MAX_EVENT_NAME_LEN,
409            name_bytes.len()
410        )));
411    }
412
413    let data_json = serde_json::to_vec(data)?;
414    if data_json.len() > MAX_EVENT_DATA_LEN {
415        return Err(NodeAppError::EventFailed(format!(
416            "Event data exceeds {} byte limit (got {})",
417            MAX_EVENT_DATA_LEN,
418            data_json.len()
419        )));
420    }
421
422    unsafe {
423        let ctx = &*ctx_ptr;
424
425        if ctx.host_publish_event as usize == 0 {
426            return Err(NodeAppError::EventFailed(
427                "host_publish_event callback not available".into(),
428            ));
429        }
430
431        let result = (ctx.host_publish_event)(
432            ctx.host_data,
433            name_bytes.as_ptr(),
434            name_bytes.len(),
435            data_json.as_ptr(),
436            data_json.len(),
437        );
438
439        if result == 0 {
440            Ok(())
441        } else {
442            Err(NodeAppError::EventFailed(format!(
443                "host_publish_event returned error code {}",
444                result
445            )))
446        }
447    }
448}
449
450/// Log a TRACE-level message using `format!`-style arguments.
451///
452/// No-op when the host context has not been wired up yet. See [`log`]
453/// for details about delivery semantics.
454#[macro_export]
455macro_rules! log_trace {
456    ($($arg:tt)*) => {
457        $crate::log($crate::log_level::TRACE, &format!($($arg)*))
458    };
459}
460
461/// Log a DEBUG-level message using `format!`-style arguments.
462#[macro_export]
463macro_rules! log_debug {
464    ($($arg:tt)*) => {
465        $crate::log($crate::log_level::DEBUG, &format!($($arg)*))
466    };
467}
468
469/// Log an INFO-level message using `format!`-style arguments.
470#[macro_export]
471macro_rules! log_info {
472    ($($arg:tt)*) => {
473        $crate::log($crate::log_level::INFO, &format!($($arg)*))
474    };
475}
476
477/// Log a WARN-level message using `format!`-style arguments.
478#[macro_export]
479macro_rules! log_warn {
480    ($($arg:tt)*) => {
481        $crate::log($crate::log_level::WARN, &format!($($arg)*))
482    };
483}
484
485/// Log an ERROR-level message using `format!`-style arguments.
486#[macro_export]
487macro_rules! log_error {
488    ($($arg:tt)*) => {
489        $crate::log($crate::log_level::ERROR, &format!($($arg)*))
490    };
491}
492
493/// Store the context pointer for use by host helper functions.
494///
495/// Called automatically by [`declare_node_app!`] during init. App code
496/// must not call this directly.
497///
498/// Uses `AtomicPtr::store` so the pointer is replaced on every init —
499/// required on macOS where `dlclose` never unloads user libraries and the
500/// same app instance is reused across hot-reloads.
501#[doc(hidden)]
502pub fn __store_context(ctx: *const NodeAppContext) {
503    APP_CONTEXT.store(ctx as *mut NodeAppContext, Ordering::Release);
504}
505
506/// Get a configuration value from the host by key.
507///
508/// Common keys include `data_dir`, `host_port`, `app_name`, and
509/// `app_id`. The full set of available keys is determined by the host
510/// at app-init time.
511///
512/// Returns `None` if the key is not registered or the context is not
513/// available (called before init).
514pub fn get_config(key: &str) -> Option<String> {
515    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
516    if ctx_ptr.is_null() {
517        return None;
518    }
519    let c_key = CString::new(key).ok()?;
520    unsafe {
521        let ctx = &*ctx_ptr;
522        let result = (ctx.host_get_config)(ctx.host_data, c_key.as_ptr());
523        if result.is_null() {
524            return None;
525        }
526        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
527    }
528}
529
530/// Set a storage value scoped to this app.
531///
532/// Storage is persisted by the host across app restarts and is isolated
533/// per-app: other apps cannot read or write keys you set here. Useful
534/// for small bits of configuration or state; for larger or relational
535/// data, prefer the `core.storage.*` capabilities.
536///
537/// No-op when the host context is not available or `key`/`value`
538/// contain interior NUL bytes.
539pub fn set_storage(key: &str, value: &str) {
540    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
541    if ctx_ptr.is_null() {
542        return;
543    }
544    let c_key = match CString::new(key) {
545        Ok(s) => s,
546        Err(_) => return,
547    };
548    let c_value = match CString::new(value) {
549        Ok(s) => s,
550        Err(_) => return,
551    };
552    unsafe {
553        let ctx = &*ctx_ptr;
554        (ctx.host_set_storage)(ctx.host_data, c_key.as_ptr(), c_value.as_ptr());
555    }
556}
557
558/// Get a storage value by key, scoped to this app.
559///
560/// See [`set_storage`] for scoping semantics. Returns `None` if the key
561/// is not found or the context is not available.
562pub fn get_storage(key: &str) -> Option<String> {
563    let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
564    if ctx_ptr.is_null() {
565        return None;
566    }
567    let c_key = CString::new(key).ok()?;
568    unsafe {
569        let ctx = &*ctx_ptr;
570        let result = (ctx.host_get_storage)(ctx.host_data, c_key.as_ptr());
571        if result.is_null() {
572            return None;
573        }
574        Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
575    }
576}
577
578/// Error type returned by app operations.
579///
580/// Variants surface specific failure modes from each lifecycle hook plus
581/// transport-level errors (serialization, capability dispatch). The
582/// `#[from] serde_json::Error` impl on [`NodeAppError::SerializationError`]
583/// allows the `?` operator to propagate JSON errors directly.
584#[derive(Debug, thiserror::Error)]
585pub enum NodeAppError {
586    /// Returned from [`NodeApp::init`] when initialization failed.
587    #[error("Initialization failed: {0}")]
588    InitFailed(String),
589    /// Returned from [`NodeApp::handle_request`] when handling failed.
590    #[error("Request handling failed: {0}")]
591    RequestFailed(String),
592    /// Returned from [`NodeApp::handle_event`] or [`publish_event`].
593    #[error("Event handling failed: {0}")]
594    EventFailed(String),
595    /// Returned from [`NodeApp::shutdown`] when graceful shutdown failed.
596    #[error("Shutdown failed: {0}")]
597    ShutdownFailed(String),
598    /// JSON (de)serialization error — auto-converted from
599    /// [`serde_json::Error`] via the `?` operator.
600    #[error("Serialization error: {0}")]
601    SerializationError(#[from] serde_json::Error),
602    /// Returned from [`NodeApp::handle_capability`] or [`invoke_capability`].
603    #[error("Capability error: {0}")]
604    CapabilityError(String),
605}
606
607/// Trait implemented by node-app plugins.
608///
609/// Pair this with [`declare_node_app!`] to generate all the FFI
610/// boilerplate. Implementors must be `Default + Send + Sync + 'static`
611/// because the macro stores the singleton instance behind a static
612/// `OnceLock<Mutex<T>>`.
613///
614/// All trait methods have sensible default implementations; override
615/// only the hooks the app uses. Apps that opt into a hook must also
616/// declare the matching capability in their manifest, e.g.
617/// `"http_handler"` for HTTP request routing.
618pub trait NodeApp: Default + Send + Sync + 'static {
619    /// Return app metadata (name, version, author, description, capabilities).
620    ///
621    /// Called once when the host loads the shared library. The values
622    /// are cached for the lifetime of the process.
623    fn metadata() -> NodeAppInfo;
624
625    /// Initialize the app with the host context.
626    ///
627    /// Called once after loading. The default impl is a no-op — override
628    /// to set up state (DB connections, caches, etc.). The context may
629    /// be `None` if the host has not wired up callbacks yet (very early
630    /// in bootstrap or in unit tests).
631    fn init(&mut self, _ctx: Option<&NodeAppContext>) -> Result<(), NodeAppError> {
632        Ok(())
633    }
634
635    /// Shut down the app gracefully.
636    ///
637    /// Called before unloading. Default is a no-op. Override to flush
638    /// pending writes, close connections, etc.
639    fn shutdown(&mut self) -> Result<(), NodeAppError> {
640        Ok(())
641    }
642
643    /// Handle an incoming HTTP request proxied from the host.
644    ///
645    /// Only invoked if the app declared the `http_handler` capability.
646    /// Default returns `501 Not Implemented`.
647    fn handle_request(&self, _request: AppRequest) -> Result<AppResponse, NodeAppError> {
648        Ok(AppResponse {
649            status: 501,
650            headers: Default::default(),
651            body: serde_json::json!({"error": "Not implemented"}),
652        })
653    }
654
655    /// Handle a domain event from the host event bus.
656    ///
657    /// Only invoked if the app declared the `event_listener` capability
658    /// and subscribed to the event's namespace via the manifest.
659    fn handle_event(&self, _event: AppEvent) -> Result<(), NodeAppError> {
660        Ok(())
661    }
662
663    /// Return the list of service capabilities this app provides.
664    ///
665    /// Override to declare capabilities for the host's capability
666    /// registry. Default returns an empty list (no capabilities
667    /// provided). Each entry must use the namespace `{app_name}.{domain}.{action}`
668    /// — the `core.*` namespace is reserved for first-party apps.
669    fn provided_capabilities() -> Vec<ProvidedCapability> {
670        Vec::new()
671    }
672
673    /// Handle a capability invocation from another app via the
674    /// capability router.
675    ///
676    /// Override to implement capability handling logic. The default
677    /// returns [`NodeAppError::CapabilityError`] indicating the
678    /// capability is not implemented.
679    ///
680    /// Trace context is automatically captured into thread-local state
681    /// for the duration of this call so [`invoke_capability`] can
682    /// propagate it on outbound calls without explicit threading.
683    fn handle_capability(
684        &self,
685        _request: CapabilityRequest,
686    ) -> Result<CapabilityResponse, NodeAppError> {
687        Err(NodeAppError::CapabilityError(
688            "Capability handling not implemented".into(),
689        ))
690    }
691}
692
693/// Generate all FFI boilerplate for a [`NodeApp`] implementation.
694///
695/// This macro creates:
696/// - A `OnceLock<Mutex<T>>` instance for the app (sound concurrent access).
697/// - The `_node_app_entry` export symbol returning a [`NodeAppVTable`].
698/// - FFI wrapper functions for `init`, `shutdown`, `handle_request`,
699///   `handle_event`, `handle_capability`, and `free`.
700/// - `catch_unwind` guards on every FFI boundary to prevent UB from
701///   panics crossing the Rust/C ABI.
702/// - Thread-local trace span management around `handle_capability`.
703///
704/// Invoke once at the crate root after defining the app type:
705///
706/// ```ignore
707/// declare_node_app!(MyApp);
708/// ```
709#[macro_export]
710macro_rules! declare_node_app {
711    ($app_type:ty) => {
712        // Feature 463 B5 fix — RwLock instead of Mutex so capability calls
713        // can run CONCURRENTLY (read-locked). The previous Mutex serialized
714        // every call, causing a deadlock when one capability (e.g. agent.prompt)
715        // invokes another capability on the SAME app re-entrantly through the
716        // graph engine (agent.prompt → graph.engine.send_event → graph node →
717        // graph.tasks.storage.* → core.agent_storage.* → BACK to agent app).
718        // init/shutdown still acquire write-lock so they remain exclusive.
719        static APP_INSTANCE: std::sync::OnceLock<std::sync::RwLock<$app_type>> =
720            std::sync::OnceLock::new();
721        static VTABLE: std::sync::OnceLock<$crate::NodeAppVTable> =
722            std::sync::OnceLock::new();
723
724        // OnceLock-backed CStrings for metadata (valid for process lifetime)
725        static META_NAME: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
726        static META_VERSION: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
727        static META_AUTHOR: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
728        static META_DESCRIPTION: std::sync::OnceLock<std::ffi::CString> =
729            std::sync::OnceLock::new();
730
731        unsafe extern "C" fn __node_app_init(
732            ctx: *const std::os::raw::c_void,
733        ) -> $crate::FfiResult {
734            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
735                let ctx_opt = if ctx.is_null() {
736                    None
737                } else {
738                    // Store context pointer for use by log() helper
739                    let ctx_typed = ctx as *const $crate::NodeAppContext;
740                    $crate::__store_context(ctx_typed);
741                    Some(unsafe { &*ctx_typed })
742                };
743                let app = APP_INSTANCE.get_or_init(|| {
744                    std::sync::RwLock::new(<$app_type>::default())
745                });
746                let mut guard = match app.write() {
747                    Ok(g) => g,
748                    Err(e) => {
749                        eprintln!("[node-app] rwlock poisoned in init: {}", e);
750                        return $crate::FfiResult::error(-10);
751                    }
752                };
753                match guard.init(ctx_opt) {
754                    Ok(()) => $crate::FfiResult::ok(),
755                    Err(e) => {
756                        let msg = format!("init error: {}", e);
757                        $crate::log($crate::log_level::ERROR, &msg);
758                        eprintln!("[node-app] {}", msg);
759                        $crate::FfiResult::error(-1)
760                    }
761                }
762            })) {
763                Ok(result) => result,
764                Err(_) => {
765                    eprintln!("[node-app] panic in init");
766                    $crate::FfiResult::error(-99)
767                }
768            }
769        }
770
771        unsafe extern "C" fn __node_app_shutdown() -> $crate::FfiResult {
772            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
773                if let Some(app) = APP_INSTANCE.get() {
774                    let mut guard = match app.write() {
775                        Ok(g) => g,
776                        Err(e) => {
777                            eprintln!("[node-app] rwlock poisoned in shutdown: {}", e);
778                            return $crate::FfiResult::error(-10);
779                        }
780                    };
781                    match guard.shutdown() {
782                        Ok(()) => $crate::FfiResult::ok(),
783                        Err(e) => {
784                            let msg = format!("shutdown error: {}", e);
785                            $crate::log($crate::log_level::ERROR, &msg);
786                            eprintln!("[node-app] {}", msg);
787                            $crate::FfiResult::error(-1)
788                        }
789                    }
790                } else {
791                    $crate::FfiResult::ok()
792                }
793            })) {
794                Ok(result) => result,
795                Err(_) => {
796                    eprintln!("[node-app] panic in shutdown");
797                    $crate::FfiResult::error(-99)
798                }
799            }
800        }
801
802        unsafe extern "C" fn __node_app_handle_request(
803            request_json: *const u8,
804            request_len: usize,
805        ) -> $crate::FfiResult {
806            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
807                let json_slice =
808                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
809                let request: $crate::AppRequest = match serde_json::from_slice(json_slice) {
810                    Ok(r) => r,
811                    Err(e) => {
812                        eprintln!("[node-app] request deserialization error: {}", e);
813                        return $crate::FfiResult::error(-2);
814                    }
815                };
816
817                let app = match APP_INSTANCE.get() {
818                    Some(a) => a,
819                    None => return $crate::FfiResult::error(-3),
820                };
821                // Feature 463 B5 — read-lock allows concurrent calls
822                let guard = match app.read() {
823                    Ok(g) => g,
824                    Err(e) => {
825                        eprintln!("[node-app] rwlock poisoned in handle_request: {}", e);
826                        return $crate::FfiResult::error(-10);
827                    }
828                };
829
830                match guard.handle_request(request) {
831                    Ok(response) => match serde_json::to_vec(&response) {
832                        Ok(bytes) => {
833                            let len = bytes.len();
834                            let boxed = bytes.into_boxed_slice();
835                            let ptr = Box::into_raw(boxed) as *mut u8;
836                            $crate::FfiResult {
837                                success: true,
838                                error_code: 0,
839                                data: ptr,
840                                data_len: len,
841                            }
842                        }
843                        Err(e) => {
844                            eprintln!("[node-app] response serialization error: {}", e);
845                            $crate::FfiResult::error(-4)
846                        }
847                    },
848                    Err(e) => {
849                        eprintln!("[node-app] handle_request error: {}", e);
850                        $crate::FfiResult::error(-5)
851                    }
852                }
853            })) {
854                Ok(result) => result,
855                Err(_) => {
856                    eprintln!("[node-app] panic in handle_request");
857                    $crate::FfiResult::error(-99)
858                }
859            }
860        }
861
862        unsafe extern "C" fn __node_app_handle_event(
863            event_json: *const u8,
864            event_len: usize,
865        ) -> $crate::FfiResult {
866            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
867                let json_slice =
868                    unsafe { std::slice::from_raw_parts(event_json, event_len) };
869                let event: $crate::AppEvent = match serde_json::from_slice(json_slice) {
870                    Ok(e) => e,
871                    Err(e) => {
872                        eprintln!("[node-app] event deserialization error: {}", e);
873                        return $crate::FfiResult::error(-2);
874                    }
875                };
876
877                let app = match APP_INSTANCE.get() {
878                    Some(a) => a,
879                    None => return $crate::FfiResult::error(-3),
880                };
881                // Feature 463 B5 — read-lock allows concurrent calls
882                let guard = match app.read() {
883                    Ok(g) => g,
884                    Err(e) => {
885                        eprintln!("[node-app] rwlock poisoned in handle_event: {}", e);
886                        return $crate::FfiResult::error(-10);
887                    }
888                };
889
890                match guard.handle_event(event) {
891                    Ok(()) => $crate::FfiResult::ok(),
892                    Err(e) => {
893                        eprintln!("[node-app] handle_event error: {}", e);
894                        $crate::FfiResult::error(-5)
895                    }
896                }
897            })) {
898                Ok(result) => result,
899                Err(_) => {
900                    eprintln!("[node-app] panic in handle_event");
901                    $crate::FfiResult::error(-99)
902                }
903            }
904        }
905
906        unsafe extern "C" fn __node_app_handle_capability(
907            request_json: *const u8,
908            request_len: usize,
909        ) -> $crate::FfiResult {
910            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
911                let json_slice =
912                    unsafe { std::slice::from_raw_parts(request_json, request_len) };
913                let request: $crate::CapabilityRequest = match serde_json::from_slice(json_slice) {
914                    Ok(r) => r,
915                    Err(e) => {
916                        eprintln!("[node-app] capability request deserialization error: {}", e);
917                        return $crate::FfiResult::error(-2);
918                    }
919                };
920
921                let app = match APP_INSTANCE.get() {
922                    Some(a) => a,
923                    None => return $crate::FfiResult::error(-3),
924                };
925                // Feature 463 B5 — read-lock allows the SAME app to handle
926                // re-entrant capability calls (e.g. agent.prompt outer call
927                // and an inner core.agent_storage.write from a graph node)
928                // concurrently. handle_capability is &self so this is sound.
929                let guard = match app.read() {
930                    Ok(g) => g,
931                    Err(e) => {
932                        eprintln!("[node-app] rwlock poisoned in handle_capability: {}", e);
933                        return $crate::FfiResult::error(-10);
934                    }
935                };
936
937                // Set thread-local trace context for duration of this capability call.
938                // This allows invoke_capability() to propagate trace automatically.
939                $crate::CURRENT_TRACE.with(|tl| {
940                    *tl.borrow_mut() = if let Some(ref trace_id) = request.trace_id {
941                        Some($crate::CurrentTrace {
942                            trace_id: trace_id.clone(),
943                            span_id: request.span_id.clone().unwrap_or_default(),
944                            depth: request.trace_depth.unwrap_or(0),
945                        })
946                    } else {
947                        None
948                    };
949                });
950
951                let _invocation_context_guard =
952                    $crate::CurrentInvocationContextGuard::enter(
953                        request.invocation_context_id.clone(),
954                    );
955
956                let cap_result = guard.handle_capability(request);
957
958                // Clear trace context after call completes.
959                $crate::CURRENT_TRACE.with(|tl| {
960                    *tl.borrow_mut() = None;
961                });
962
963                match cap_result {
964                    Ok(response) => match serde_json::to_vec(&response) {
965                        Ok(bytes) => {
966                            // Enforce 16MB response limit
967                            if bytes.len() > $crate::MAX_CAPABILITY_RESPONSE_SIZE {
968                                eprintln!(
969                                    "[node-app] capability response exceeds 16MB limit ({} bytes)",
970                                    bytes.len()
971                                );
972                                return $crate::FfiResult::error(-6);
973                            }
974                            let len = bytes.len();
975                            let boxed = bytes.into_boxed_slice();
976                            let ptr = Box::into_raw(boxed) as *mut u8;
977                            $crate::FfiResult {
978                                success: true,
979                                error_code: 0,
980                                data: ptr,
981                                data_len: len,
982                            }
983                        }
984                        Err(e) => {
985                            eprintln!("[node-app] capability response serialization error: {}", e);
986                            $crate::FfiResult::error(-4)
987                        }
988                    },
989                    Err(e) => {
990                        eprintln!("[node-app] handle_capability error: {}", e);
991                        $crate::FfiResult::error(-5)
992                    }
993                }
994            })) {
995                Ok(result) => result,
996                Err(_) => {
997                    eprintln!("[node-app] panic in handle_capability");
998                    $crate::FfiResult::error(-99)
999                }
1000            }
1001        }
1002
1003        unsafe extern "C" fn __node_app_free(ptr: *mut u8, len: usize) {
1004            if !ptr.is_null() && len > 0 {
1005                let _ = unsafe { Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)) };
1006            }
1007        }
1008
1009        #[no_mangle]
1010        pub unsafe extern "C" fn _node_app_entry(
1011            _ctx: *const std::os::raw::c_void,
1012        ) -> *const $crate::NodeAppVTable {
1013            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1014                let info = <$app_type as $crate::NodeApp>::metadata();
1015                let caps = info.capability_flags();
1016
1017                let name = META_NAME
1018                    .get_or_init(|| std::ffi::CString::new(info.name).unwrap_or_default());
1019                let version = META_VERSION
1020                    .get_or_init(|| std::ffi::CString::new(info.version).unwrap_or_default());
1021                let author = META_AUTHOR
1022                    .get_or_init(|| std::ffi::CString::new(info.author).unwrap_or_default());
1023                let description = META_DESCRIPTION
1024                    .get_or_init(|| std::ffi::CString::new(info.description).unwrap_or_default());
1025
1026                let metadata = $crate::NodeAppMetadata {
1027                    api_version: $crate::API_VERSION,
1028                    name: name.as_ptr(),
1029                    version: version.as_ptr(),
1030                    author: author.as_ptr(),
1031                    description: description.as_ptr(),
1032                    capabilities: caps.bits(),
1033                };
1034
1035                VTABLE.get_or_init(|| $crate::NodeAppVTable {
1036                    metadata,
1037                    init: __node_app_init,
1038                    shutdown: __node_app_shutdown,
1039                    handle_request: __node_app_handle_request,
1040                    handle_event: __node_app_handle_event,
1041                    handle_capability: __node_app_handle_capability,
1042                    free: __node_app_free,
1043                }) as *const $crate::NodeAppVTable
1044            })) {
1045                Ok(ptr) => ptr,
1046                Err(_) => {
1047                    eprintln!("[node-app] panic in _node_app_entry");
1048                    std::ptr::null()
1049                }
1050            }
1051        }
1052    };
1053}
1054
1055#[cfg(test)]
1056mod invocation_context_tests {
1057    use std::ffi::{c_char, c_void};
1058    use std::sync::Mutex;
1059
1060    use super::*;
1061
1062    static TEST_LOCK: Mutex<()> = Mutex::new(());
1063
1064    unsafe extern "C" fn noop_log(_: *const c_void, _: u32, _: *const c_char) {}
1065    unsafe extern "C" fn no_config(_: *const c_void, _: *const c_char) -> *const c_char {
1066        std::ptr::null()
1067    }
1068    unsafe extern "C" fn noop_storage(_: *const c_void, _: *const c_char, _: *const c_char) {}
1069    unsafe extern "C" fn no_storage(_: *const c_void, _: *const c_char) -> *const c_char {
1070        std::ptr::null()
1071    }
1072    unsafe extern "C" fn noop_publish(
1073        _: *const c_void,
1074        _: *const u8,
1075        _: usize,
1076        _: *const u8,
1077        _: usize,
1078    ) -> i32 {
1079        0
1080    }
1081    unsafe extern "C" fn capture_invoke(
1082        host_data: *const c_void,
1083        request_json: *const u8,
1084        request_len: usize,
1085    ) -> FfiResult {
1086        let requests = &*(host_data as *const Mutex<Vec<CapabilityRequest>>);
1087        let bytes = std::slice::from_raw_parts(request_json, request_len);
1088        requests
1089            .lock()
1090            .unwrap()
1091            .push(serde_json::from_slice(bytes).unwrap());
1092        let response = CapabilityResponse {
1093            id: "nested-1".into(),
1094            success: true,
1095            payload: serde_json::json!({"ok": true}),
1096        };
1097        let bytes = serde_json::to_vec(&response).unwrap().into_boxed_slice();
1098        let len = bytes.len();
1099        let data = Box::into_raw(bytes) as *mut u8;
1100        FfiResult {
1101            success: true,
1102            error_code: 0,
1103            data,
1104            data_len: len,
1105        }
1106    }
1107
1108    #[test]
1109    fn nested_invocation_propagates_opaque_context() {
1110        let _serial = TEST_LOCK.lock().unwrap();
1111        let captured = Mutex::new(Vec::<CapabilityRequest>::new());
1112        let context = NodeAppContext {
1113            host_data: &captured as *const _ as *const c_void,
1114            host_log: noop_log,
1115            host_get_config: no_config,
1116            host_set_storage: noop_storage,
1117            host_get_storage: no_storage,
1118            host_invoke_capability: capture_invoke,
1119            host_publish_event: noop_publish,
1120        };
1121        __store_context(&context);
1122        CURRENT_TRACE.with(|current| {
1123            *current.borrow_mut() = Some(CurrentTrace {
1124                trace_id: "trace-1".into(),
1125                span_id: "span-1".into(),
1126                depth: 4,
1127            });
1128        });
1129        let _invocation =
1130            CurrentInvocationContextGuard::enter(Some("opaque-context-1".into()));
1131
1132        invoke_capability(&CapabilityRequest {
1133            id: "nested-1".into(),
1134            capability: "example.nested".into(),
1135            payload: serde_json::json!({}),
1136            ..Default::default()
1137        })
1138        .unwrap();
1139
1140        let requests = captured.lock().unwrap();
1141        assert_eq!(requests.len(), 1);
1142        assert_eq!(
1143            requests[0].invocation_context_id.as_deref(),
1144            Some("opaque-context-1")
1145        );
1146        assert_eq!(requests[0].trace_id.as_deref(), Some("trace-1"));
1147        assert_eq!(requests[0].span_id.as_deref(), Some("span-1"));
1148        drop(requests);
1149        CURRENT_TRACE.with(|current| *current.borrow_mut() = None);
1150        __store_context(std::ptr::null());
1151    }
1152}