Skip to content

Commit 1290a18

Browse files
feat(pj_base): add pj.data_processors.v1 data-only SDK service for plugin-created transforms (#125)
Adds the `pj.data_processors.v1` host service: a DATA-ONLY C ABI that lets a toolbox plugin create catalog-resident "transform" nodes in the host. Only strings cross the boundary (script source + input/output names + params JSON + an id); the host owns all execution, so a transform survives plugin unload and session reload — the originating plugin is needed only for re-editing. - PJ_data_processors_host_vtable_t (create/remove/list/config), protocol_version 1, struct_size-gated and ABI-appendable; all slots [main-thread], PJ_NOEXCEPT. - sdk::DataProcessorsHostView C++ sugar + DataProcessorsHostService trait. - Payload (script/params) is binary-safe (PJ_string_view_t {data,size}), so the native "door" is WASM bytes through this same surface — a future host-owned WASM/Python backend is purely additive and survives unload, deliberately NOT a C++ kernel vtable that would dangle on DSO unload. - The view now rejects an empty output list (transforms create named topics, so >=1 output is required) before it ever reaches the vtable. - Fake-host gtest suite (8 cases) incl. a binary-safe payload round-trip and the empty-outputs guard. Additive new service -> Conan MINOR (0.8.0 -> 0.9.0); abi/baseline.abi untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 283d540 commit 1290a18

8 files changed

Lines changed: 437 additions & 4 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ endif()
111111
if(PJ_INSTALL_SDK)
112112
include(CMakePackageConfigHelpers)
113113

114-
set(PJ_PACKAGE_VERSION "0.8.1")
114+
set(PJ_PACKAGE_VERSION "0.9.0")
115115
set(PJ_PACKAGE_CMAKE_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/plotjuggler_sdk)
116116

117117
install(EXPORT plotjuggler_sdkTargets

conanfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
plugin_sdk — umbrella for plugin authors (base + dialog SDK + parser SDK)
77
plugin_host — umbrella for host loaders (data_source/parser/toolbox/dialog)
88
9-
A consuming Conan recipe declares e.g. `plotjuggler_sdk/0.8.1` and then:
9+
A consuming Conan recipe declares e.g. `plotjuggler_sdk/0.9.0` and then:
1010
1111
find_package(plotjuggler_sdk REQUIRED COMPONENTS plugin_sdk)
1212
target_link_libraries(my_plugin PRIVATE plotjuggler_sdk::plugin_sdk)
@@ -30,7 +30,7 @@
3030

3131
class PlotjugglerSdkConan(ConanFile):
3232
name = "plotjuggler_sdk"
33-
version = "0.8.1"
33+
version = "0.9.0"
3434
# Apache-2.0 covers the whole SDK (pj_base + pj_plugins). See LICENSE.
3535
license = "Apache-2.0"
3636
url = "https://github.com/PlotJuggler/plotjuggler_sdk"

pj_base/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ if(PJ_BUILD_TESTS)
7272
tests/expected_test.cpp
7373
tests/number_parse_test.cpp
7474
tests/plugin_data_api_test.cpp
75+
tests/data_processors_api_test.cpp
7576
tests/settings_store_host_test.cpp
7677
tests/data_source_protocol_test.cpp
7778
# TODO: data_source_plugin_base_test.cpp and

pj_base/include/pj_base/plugin_data_api.h

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,85 @@ typedef struct {
754754
const PJ_colormap_registry_vtable_t* vtable;
755755
} PJ_colormap_registry_t;
756756

757+
/**
758+
* Data-processor host service ("pj.data_processors.v1", protocol_version 1).
759+
*
760+
* Lets a toolbox plugin create catalog-resident "transform" nodes in the host
761+
* by DATA only. Nothing executable crosses the boundary — only strings:
762+
*
763+
* - id : per-call stable key. The host namespaces it under the
764+
* calling plugin. create() UPSERTS by id (idempotent Save).
765+
* ids survive a session reload, so a plugin can re-edit its
766+
* nodes after the host has replayed them from persisted
767+
* recipes (see data_processor_config).
768+
* - inputs : topic OR topic-field names ("pose/orientation" or
769+
* "pose/orientation/x"); the host resolves them and exact-joins
770+
* co-timestamped inputs. Field-level resolution is a host
771+
* concern and needs no ABI change.
772+
* - outputs : REQUIRED non-empty. This service creates named catalog
773+
* topics (transforms). View-bound hidden-output "filters" are
774+
* host-internal and are NOT created through this ABI.
775+
* - script : the processor class source. Its first-line directive
776+
* ("-- pj-script: <lang>") selects the backend; an unsupported
777+
* one is rejected by the host at create() time. BINARY-SAFE:
778+
* script is a PJ_string_view_t {data,size}, NOT a C string, so
779+
* it MAY carry a non-text blob — e.g. a future WASM module
780+
* detected by the leading "\0asm" magic. Nothing on the path
781+
* may treat it as NUL-terminated (no strlen).
782+
* - params_json : forwarded VERBATIM to the backend's create(params); the host
783+
* does not interpret its keys (translucent pass-through). Also
784+
* a binary-safe PJ_string_view_t {data,size}.
785+
*
786+
* Per-plugin isolation: the host namespaces every id under the calling plugin, so
787+
* list/remove/config only ever see or affect THAT plugin's nodes; one plugin can
788+
* neither enumerate nor remove another's.
789+
*
790+
* FORWARD-COMPAT — the native door is WASM, not a C++ kernel. Because the script
791+
* slot is a binary-safe blob the host owns and runs (today Luau; tomorrow a
792+
* host-owned WASM/Python backend), a native processor needs NO new ABI: it ships
793+
* as bytes through this same data-only surface, survives plugin unload (the host
794+
* owns the payload + runtime), and is sandboxed. Adding a backend is purely
795+
* additive. This is deliberately the OPPOSITE of crossing a C++ vtable across the
796+
* DSO boundary (which dangles on unload).
797+
*
798+
* ABI-APPENDABLE: new slots may be added at the tail; struct_size gates read.
799+
*/
800+
typedef struct PJ_data_processors_host_vtable_t {
801+
uint32_t protocol_version; // = 1
802+
uint32_t struct_size; // = sizeof(PJ_data_processors_host_vtable_t)
803+
804+
/* [main-thread] Create or replace (upsert by id) a transform node. outputs
805+
* MUST be non-empty. Transactional: on failure no partial node/topic is left
806+
* behind. All string arguments are borrowed for the duration of the call. */
807+
bool (*create_data_processor)(
808+
void* ctx, PJ_string_view_t id, const PJ_string_view_t* inputs, uint64_t input_count,
809+
const PJ_string_view_t* outputs, uint64_t output_count, PJ_string_view_t script, PJ_string_view_t params_json,
810+
PJ_error_t* out_error) PJ_NOEXCEPT;
811+
812+
/* [main-thread] Remove a node by id. An unknown id is an error. */
813+
bool (*remove_data_processor)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT;
814+
815+
/* [main-thread] Enumerate the ids of THIS plugin's live processors.
816+
* Count-then-fill: pass capacity 0 to read *out_count, then call again with a
817+
* buffer of that size. On success the first min(capacity, *out_count) entries
818+
* of out_ids are filled and point into host storage valid only until the next
819+
* call on this vtable. */
820+
bool (*list_data_processor_ids)(
821+
void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) PJ_NOEXCEPT;
822+
823+
/* [main-thread] Read a node's full recipe as JSON
824+
* {"inputs":[...],"outputs":[...],"params":{...}} for re-edit (e.g. after a
825+
* session reload). *out_recipe_json is borrowed, valid only until the next
826+
* call on this vtable. An unknown id is an error. */
827+
bool (*data_processor_config)(
828+
void* ctx, PJ_string_view_t id, PJ_string_view_t* out_recipe_json, PJ_error_t* out_error) PJ_NOEXCEPT;
829+
} PJ_data_processors_host_vtable_t;
830+
831+
typedef struct {
832+
void* ctx;
833+
const PJ_data_processors_host_vtable_t* vtable;
834+
} PJ_data_processors_host_t;
835+
757836
/**
758837
* Settings store service ("pj.settings.v1", protocol_version 1).
759838
*

pj_base/include/pj_base/sdk/plugin_data_api.hpp

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,109 @@ class ColorMapRegistryView {
12861286
PJ_colormap_registry_t registry_{};
12871287
};
12881288

1289+
// ---------------------------------------------------------------------------
1290+
// DataProcessorsHostView — typed C++ view over PJ_data_processors_host_t
1291+
// ---------------------------------------------------------------------------
1292+
1293+
/// C++ wrapper around PJ_data_processors_host_t for plugins that create
1294+
/// catalog-resident transform nodes in the host (see the C ABI doc-comment on
1295+
/// PJ_data_processors_host_vtable_t). Empty-constructible; `valid()` tells
1296+
/// whether the host exposed the service. Strings returned by `list()`/
1297+
/// `recipeOf()` are copied into owned values, so they stay valid past the next
1298+
/// vtable call.
1299+
class DataProcessorsHostView {
1300+
public:
1301+
DataProcessorsHostView() = default;
1302+
explicit DataProcessorsHostView(PJ_data_processors_host_t host) : host_(host) {}
1303+
1304+
[[nodiscard]] bool valid() const noexcept {
1305+
return host_.vtable != nullptr && host_.ctx != nullptr;
1306+
}
1307+
1308+
/// Create or replace (upsert by id) a transform node. `outputs` must be
1309+
/// non-empty (the host rejects empty output lists). `params_json` is
1310+
/// forwarded verbatim to the script's create(params).
1311+
[[nodiscard]] Status createTransform(
1312+
std::string_view id, Span<const std::string_view> inputs, Span<const std::string_view> outputs,
1313+
std::string_view script, std::string_view params_json) const {
1314+
if (!valid() || host_.vtable->create_data_processor == nullptr) {
1315+
return unexpected("data processors host is not bound");
1316+
}
1317+
if (outputs.empty()) {
1318+
return unexpected("data processors transform requires at least one output topic");
1319+
}
1320+
std::vector<PJ_string_view_t> in_abi;
1321+
in_abi.reserve(inputs.size());
1322+
for (const auto& name : inputs) {
1323+
in_abi.push_back(toAbiString(name));
1324+
}
1325+
std::vector<PJ_string_view_t> out_abi;
1326+
out_abi.reserve(outputs.size());
1327+
for (const auto& name : outputs) {
1328+
out_abi.push_back(toAbiString(name));
1329+
}
1330+
PJ_error_t err{};
1331+
if (!host_.vtable->create_data_processor(
1332+
host_.ctx, toAbiString(id), in_abi.data(), in_abi.size(), out_abi.data(), out_abi.size(),
1333+
toAbiString(script), toAbiString(params_json), &err)) {
1334+
return unexpected(errorToString(err));
1335+
}
1336+
return okStatus();
1337+
}
1338+
1339+
/// Remove a previously created node by id.
1340+
[[nodiscard]] Status remove(std::string_view id) const {
1341+
if (!valid() || host_.vtable->remove_data_processor == nullptr) {
1342+
return unexpected("data processors host is not bound");
1343+
}
1344+
PJ_error_t err{};
1345+
if (!host_.vtable->remove_data_processor(host_.ctx, toAbiString(id), &err)) {
1346+
return unexpected(errorToString(err));
1347+
}
1348+
return okStatus();
1349+
}
1350+
1351+
/// Enumerate the ids of this plugin's live processors (owned copies).
1352+
[[nodiscard]] Expected<std::vector<std::string>> list() const {
1353+
if (!valid() || host_.vtable->list_data_processor_ids == nullptr) {
1354+
return unexpected("data processors host is not bound");
1355+
}
1356+
PJ_error_t err{};
1357+
uint64_t count = 0;
1358+
if (!host_.vtable->list_data_processor_ids(host_.ctx, nullptr, 0, &count, &err)) {
1359+
return unexpected(errorToString(err));
1360+
}
1361+
std::vector<PJ_string_view_t> borrowed(count);
1362+
uint64_t filled = 0;
1363+
if (count != 0 &&
1364+
!host_.vtable->list_data_processor_ids(host_.ctx, borrowed.data(), borrowed.size(), &filled, &err)) {
1365+
return unexpected(errorToString(err));
1366+
}
1367+
std::vector<std::string> ids;
1368+
ids.reserve(filled);
1369+
for (uint64_t i = 0; i < filled; ++i) {
1370+
ids.emplace_back(toStringView(borrowed[i]));
1371+
}
1372+
return ids;
1373+
}
1374+
1375+
/// Read a node's full recipe JSON (owned copy) for re-edit.
1376+
[[nodiscard]] Expected<std::string> recipeOf(std::string_view id) const {
1377+
if (!valid() || host_.vtable->data_processor_config == nullptr) {
1378+
return unexpected("data processors host is not bound");
1379+
}
1380+
PJ_error_t err{};
1381+
PJ_string_view_t recipe{};
1382+
if (!host_.vtable->data_processor_config(host_.ctx, toAbiString(id), &recipe, &err)) {
1383+
return unexpected(errorToString(err));
1384+
}
1385+
return std::string(toStringView(recipe));
1386+
}
1387+
1388+
private:
1389+
PJ_data_processors_host_t host_{};
1390+
};
1391+
12891392
// ---------------------------------------------------------------------------
12901393
// SettingsView — typed C++ view over PJ_settings_store_t
12911394
// ---------------------------------------------------------------------------

pj_base/include/pj_base/sdk/service_traits.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,19 @@ struct ColorMapRegistryService {
158158
static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule");
159159
};
160160

161+
/// Optional service for plugins that create catalog-resident transform nodes in
162+
/// the host by data (script + input/output names + params JSON). Nothing
163+
/// executable crosses the boundary; the host owns execution. See the C ABI
164+
/// doc-comment on PJ_data_processors_host_vtable_t.
165+
struct DataProcessorsHostService {
166+
static constexpr const char* kName = "pj.data_processors.v1";
167+
static constexpr uint32_t kMinVersion = 1;
168+
using Raw = PJ_data_processors_host_t;
169+
using Vtable = PJ_data_processors_host_vtable_t;
170+
using View = DataProcessorsHostView;
171+
static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule");
172+
};
173+
161174
/// Optional QSettings-like key/value persistence exposed to any plugin family.
162175
/// Host-backed (QSettings in the GUI app, JSON in a headless host); keys are
163176
/// namespaced per plugin by the host.

0 commit comments

Comments
 (0)