Files
logos-cpp-sdk/doctests/cpp-sdk-module-composition.test.yaml
T
Dario LipicarandClaude Opus 5 198f0317ca feat(optional): ?T is two-state, and the generators finally read it (#125)
* feat(optional): ?T is two-state, and the generators finally read it

No generator in any language read the optional flag — it had never been
implemented. `?T` was a HARD REJECT on the cdylib backend ("module not
cdylib-eligible"), `std::optional<T>` in an impl header fell through to the
opaque `any` with no diagnostic, and a `? name: T` field was emitted as a
required `T`. Three real contracts in the workspace already declare optionals
and were silently getting one of those three answers.

`?T` is TWO-state: a value of T, or empty. Never three — "one LIDL type <-> one
type per language" leaves nowhere for a third state, because every target has
exactly one empty inhabitant.

ONE MEANING, TWO SPELLINGS. `? name: T` (the field flag) and `name: ?T` (the
type kind) are the same declaration. Backends no longer answer that themselves:
logos-lidl's fieldIsOptional/fieldValueType are re-exported from lidl_compat.h
and every site THIS COMMIT TOUCHES reads them, so the two spellings emit
byte-identical code on the cdylib and client backends. That
caught a live drift on the way in — lidlRecordCollidesWithBytesTag read `f.type`
and so refused `? _bytes: tstr` while letting `_bytes: ?tstr` straight through,
one declaration with two answers.

THE WIRE RULE DEPENDS ON THE SLOT. Absent and explicit null are the SAME state
on decode and DIFFERENT on encode:
  - decode is liberal, by exactly one inhabitant: in an optional slot absent and
    null both mean empty; in a required slot both stay errors. A present value
    goes through the decoder a required T would get, so a wrong type still fails
    at the same path — optional widens the domain, it does not switch checking
    off. `?bstr` therefore keeps the LENIENT bytes decode a bare `bstr` gets,
    rather than silently becoming stricter in the optional slot.
  - encode has one canonical form: empty OMITS the key where the slot is NAMED
    (a record field) and is spelled null where it is POSITIONAL (an argument, a
    return, an event parameter — no key to omit, and arity must not change). Key
    omission lives in the record emitter because a Codec only ever sees a value,
    never the slot it sits in. A round trip therefore canonicalises.
  - `?any` collapses onto `any`: nlohmann::json already carries null, so
    std::optional<LogosMap> would give the slot two spellings of empty.

The dispatch gate now admits a missing trailing optional argument and
materialises it as null, exactly the way a missing record field already was. A
method with no optional parameter emits the byte-identical gate it always did.

Header-first: `std::optional<T>` <-> `?T`, composing with records and
containers. `std::optional<std::optional<T>>` has NO LIDL type (three C++ states
over a two-state wire), so it maps down to `?T` — which makes the author's own
declaration stop compiling against the generated codec, deliberately — and says
so at derivation time instead of leaving a conversion error in generated code.

The Qt/Lp consumer surface is NOT fixed and does not pretend to be. The wrappers
real modules get come from legacy/main.cpp, where the AST is flattened to a
single Qt type-name string per slot before optionality could be seen; Qt has no
optional metatype, so `?T` lands on QVariant — the right shape (an invalid
QVariant is Qt's empty inhabitant) with no type. The generator now prints a Note
naming every flattened slot so an affected build is never silent, and
docs/project.md records exactly what a Qt consumer will still do with an
optional field.

Verified by output equivalence, not by a green build: the generator was built
before and after and run over every .lidl in the workspace plus the impl-header
fixtures, in cdylib, consumer-qt, consumer-lp, client and header-first modes.
428 of 465 artefacts are byte-identical; all 37 that differ belong to one of the
four contracts that declare an optional (the 38th path is the manifest). The
harness's sensitivity is pinned by a negative control: qt vs lp output differs
in 45 files. The emitted codec was additionally compiled under -Wall -Wextra and
run against the rules above — omission, absent==null, required-still-rejects,
present-but-wrong-still-fails, and canonicalising round trip.

Tests: 199 pass, 0 fail (180 before, 19 new).

Requires logos-lidl's optionality accessors and logos-protocol's
Codec<std::optional<T>>.

NOT FIXED, AND IT IS THE PATH THAT MATTERS MOST. The legacy interface-wrapper
path is untouched, and it is the one every real module builds through
(buildPlugin.nix:145 -> logos-cpp-generator --general-only). There the two
spellings still diverge:

  ? maybe: tstr   ->  QString maybe{};   __m.value("maybe").toString()
  maybe: ?tstr    ->  QVariant maybe{};  __m.value("maybe")

and --api-style lp diverges too, neither side being std::optional. So R3 holds
on the backends below and NOT on the Qt consumer a shipping module actually
gets. logos-chat-module -- the contract that prompted this work -- uses the
field-flag spelling, so it lands on the branch that silently defaults.

The cause is upstream of codegen: legacy/main.cpp's moduleRecordsToJson and
moduleMethodsToJson flatten every TypeExpr to a single Qt TYPE-NAME STRING, so
optionality (along with nesting, map key types and descriptions) is gone before
generator_lib.cpp sees it. Widening that interface is a larger change and is
deliberately not attempted here. The only R3 test on a Qt surface covers
lidl_gen_client.cpp, which is on no live build path.

* chore: re-pin logos-lidl to master for the optionality accessors

lidl_compat.h re-exports typeIsOptional / optionalValueType / fieldIsOptional /
fieldValueType / paramIsOptional / paramValueType, which landed in
logos-lidl#7. The pinned lidl predated it, so CI failed to compile.

logos-lidl 8c95d4f -> 35f33d8. Tests: 199 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: re-pin logos-protocol to master for Codec<std::optional<T>>

The generated record codecs emit Codec<std::optional<T>> for an optional
field; that specialisation landed in logos-protocol#37 and the pinned
protocol predated it.

Note this repo's own tests would NOT have caught the omission -- the
generator tests string-assert emitted text rather than compiling it, so a
missing codec specialisation only surfaces when a real module compiles
generated optional code (logos-test-modules' ext provider).

logos-protocol 4359557 -> 72754ab. Tests: 199 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctests): override logos-lidl alongside every logos-cpp-sdk override

The doc-tests build downstream repos (logoscore-cli, capability_module,
accounts_module) with --override-input logos-cpp-sdk. Nix does not carry the
overridden input's OWN lock, so those builds got this branch's cpp-sdk source
while still resolving logos-lidl from their own, older locks. The shipped
share/lidl-frontend/lidl_compat.h then calls accessors that lidl does not
have:

  lidl_compat.h:46: error: 'paramValueType' has not been declared in 'lidl'
  lidl_compat.h:92: error: 'fieldValueType' was not declared in this scope

Every --override-input logos-cpp-sdk now has a matching
--override-input <same-path>/logos-cpp-sdk/logos-lidl.

This is specific to the override path. A normal consumer running
'nix flake update logos-cpp-sdk' inherits cpp-sdk's own lock, which pins the
lidl carrying these accessors, and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctests): move logos-lidl at the qt-sdk nodes, not under logos-cpp-sdk

The doc-tests failed to build logos-qt-generator:

  share/lidl-frontend/lidl_compat.h:46: error: 'paramValueType' has not been
  declared in 'lidl'

MECHANISM. This SDK installs cpp-generator/experimental/lidl_compat.h into
$out/share/lidl-frontend/, and logos-qt-sdk's logos-qt-generator *compiles*
that installed header against qt-sdk's OWN logos-lidl input. Under
logos-qt-sdk, logos-lidl is a SIBLING of logos-cpp-sdk, not a descendant:

  logos-qt-sdk
  |-- logos-cpp-sdk   <- --override-input moves this to the commit under test
  `-- logos-lidl      <- stays on qt-sdk's lock (8c95d4f), lacks the accessors

logos-logoscore-cli and logos-module-builder both declare
`logos-qt-sdk.inputs.logos-cpp-sdk.follows = "logos-cpp-sdk"` but no lidl
follows, so overriding the SDK hands qt-sdk a new lidl_compat.h next to its
old lidl. The failing derivation is logos-qt-generator — not anything in
logos-cpp-sdk, which is why the previous attempt aimed at the wrong node.

THE FIX is one `<path-to-logos-qt-sdk>/logos-lidl` override per qt-sdk node
that ends up on the SDK under test. A tree-walk over the resolved lock found
four in logoscore-cli's closure and two per module build; with the overrides
applied the walk reports zero remaining.

WHAT WAS REMOVED, and why it was doing nothing:

  * The `.../logos-cpp-sdk/logos-lidl` overrides added in bef3ef5 were no-ops.
    With only `--override-input logos-cpp-sdk <sha>`, that node's logos-lidl
    already resolves to 35f33d87 out of cpp-sdk's own lock — nix >= 2.26
    carries an overridden input's lock, and CI runs Determinate Nix. Verified
    by resolving the lock with and without them: byte-identical.
  * The `logos-module-client/...` overrides never matched anything. Nix says so
    out loud ("does not match any input"): logoscore-cli has no such root
    input; module-client only appears under logos-test-modules/, outside the
    runtime closure. The prose claiming it pins the SDK is corrected too.

cpp-sdk-concurrent-dispatch is fixed here as well — it failed the same way and
carried no lidl overrides at all.

VERIFIED locally against bef3ef5, the exact commit CI failed on:

  * accounts .lgx  -> exit 0, logos-accounts_module-module-lib.lgx (5,939,898 B)
  * logoscore CLI  -> exit 0, ./logos/bin/logoscore reports
                      "logos-cpp-sdk bef3ef57d3f489073672e70a786c550df7edd003"
  * negative control (same command minus the single qt-sdk lidl flag) fails
    with CI's exact derivation,
    /nix/store/pf96n2ldvhy6sq39ygkh5zdqx7dcn4df-logos-qt-generator-0.1.0.drv
  * no "does not match any input" warnings remain on any command

The durable fix is a one-line bump of logos-qt-sdk's own flake.lock logos-lidl
to master (logos-lidl#7 is purely additive: six new inline helpers, nothing
removed or renamed). Once qt-sdk carries it, every override added here can go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:19:47 -03:00

826 lines
37 KiB
YAML

name: "Composing Two Modules Built Against This C++ SDK"
output: cpp-sdk-module-composition.md
release: ""
intro: |
The whole point of `logos-cpp-sdk` is that a module can call **another**
module without ever touching the raw `LogosAPI`: you declare a dependency,
and the SDK's code generator emits a typed `modules().<dep>` wrapper with
synchronous callers, asynchronous callers, and event subscribers. This
doc-test proves that cross-module path works end-to-end on the SDK commit
under test — by building *both* sides from scratch against it.
It is fully self-contained — no pre-existing module, no `requires:` chain:
1. Create `greeter_module`, a small **callee** with a couple of methods
(`greet`, `addInts`, `greetCount`) and two events — `greeted`, carrying a
string, and `blobReady`, carrying raw bytes.
2. Create `orchestrator_module`, a **caller** that declares `greeter_module`
as a dependency and composes it through the generated
`modules().greeter_module` wrapper — synchronously, asynchronously, and by
subscribing to both of its events.
3. Build **both** modules' `.lgx` packages **against the C++ SDK commit under
test**, so the generated wrappers, the plugin glue, and the IPC layer all
come from this SDK.
4. Build `logoscore` (also against this SDK), install both modules, load them
together, and call the orchestrator's methods — each of which calls into
the greeter across the process boundary.
Because the caller, the callee, the generated cross-module wrappers, and the
runtime are all built from the SDK commit under test, a green run is real
evidence that this change keeps inter-module composition — the SDK's core
promise — working.
what_you_build: "Two modules — a `greeter_module` callee and an `orchestrator_module` caller — built against this SDK commit and run together in `logoscore`, with the caller driving the callee over IPC."
what_you_learn:
- How one module declares another as a dependency (`metadata.json` + `flake.nix` input)
- How the SDK generates a typed `modules().<dep>` wrapper with sync, async, and event APIs
- How to build a module — and its module dependency — against a specific `logos-cpp-sdk` commit
- How to load two modules in `logoscore` and chain calls so the caller drives the callee
- How async replies and event subscriptions survive between `call` commands under the daemon
- How a **binary** (`bstr`) event payload crosses the boundary intact, encoded and decoded by generated code on both sides
prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"`
- "**git** — nix flakes only see files tracked by git."
- "A Linux or macOS machine."
sections:
- title: "Create the callee: greeter_module"
step: true
text: |
`greeter_module` is an ordinary `core` module written in the pure-C++
(`interface: universal`) style: you write one plain class, and the builder
generates the Qt plugin glue. Every `public` method becomes callable over
IPC, and the `logos_events:` block declares an event other modules can
subscribe to.
steps:
- title: "metadata.json"
text: |
`dependencies` is empty — the greeter calls no one. `interface:
universal` selects the pure-C++ pattern.
file:
path: greeter_module/metadata.json
language: json
content: |
{
"name": "greeter_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "A callee module: greets, counts greetings, and emits an event",
"main": "greeter_module_plugin",
"interface": "universal",
"dependencies": [],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": []
}
}
}
- title: "CMakeLists.txt"
text: "For a universal module you list only your plain C++ sources; the generated glue is compiled automatically."
file:
path: greeter_module/CMakeLists.txt
language: cmake
content: |
cmake_minimum_required(VERSION 3.14)
project(GreeterModulePlugin LANGUAGES CXX)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
include(cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found")
endif()
logos_module(
NAME greeter_module
SOURCES
src/greeter_module_impl.h
src/greeter_module_impl.cpp
)
- title: "flake.nix"
text: |
A minimal flake that hands every input to `mkLogosModule`. The
`{release}` on the builder URL is pinned by the doc-test runner; the
builder owns the module's `logos-cpp-sdk` pin, which we override to the
commit under test in the build step.
file:
path: greeter_module/flake.nix
language: nix
content: |
{
description = "Greeter core module - a callee for the composition doc-test";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder{release}";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
- title: "src/greeter_module_impl.h — the class"
text: |
Plain C++ inheriting `LogosModuleContext`. The `///` doc comments
become each method's description; the `logos_events:` block declares
the `greeted` event (the token expands to `public` under a normal
compile, and the generator emits the event body).
file:
path: greeter_module/src/greeter_module_impl.h
language: cpp
content: |
#pragma once
#include <cstdint>
#include <string>
#include <logos_module_context.h> // LogosModuleContext base + logos_events
// A simple callee module. The orchestrator composes these methods over
// IPC. It also emits a `greeted` event so the caller can exercise a
// typed event subscription.
class GreeterModuleImpl : public LogosModuleContext {
public:
GreeterModuleImpl() = default;
~GreeterModuleImpl() = default;
/// Returns a greeting for the given name.
std::string greet(const std::string& name);
/// Adds two integers and returns the sum.
int64_t addInts(int64_t a, int64_t b);
/// Returns how many times greet() has been called on this instance.
int64_t greetCount() const;
/// Greets the name and also emits a `greeted` event carrying it.
void greetNotify(const std::string& name);
/// Builds a blob of `size` bytes and emits it on `blobReady`.
/// Returns the number of bytes emitted.
int64_t emitBlob(int64_t size);
logos_events:
/// Emitted by greetNotify() with the produced greeting string.
void greeted(const std::string& greeting);
/// Emitted by emitBlob() carrying raw bytes. `bstr` payloads take
/// the canonical tagged form on the wire; the generated code on
/// both sides encodes and decodes them, so the author on either
/// end only ever sees a `std::vector<uint8_t>`.
void blobReady(const std::string& label,
const std::vector<uint8_t>& payload);
private:
int64_t m_greetCount = 0;
};
- title: "src/greeter_module_impl.cpp — the implementation"
text: "Plain C++ — no Qt, no IPC plumbing. `greetNotify` fires the generated event."
file:
path: greeter_module/src/greeter_module_impl.cpp
language: cpp
content: |
#include "greeter_module_impl.h"
std::string GreeterModuleImpl::greet(const std::string& name)
{
++m_greetCount;
return "Hello, " + name + "!";
}
int64_t GreeterModuleImpl::addInts(int64_t a, int64_t b)
{
return a + b;
}
int64_t GreeterModuleImpl::greetCount() const
{
return m_greetCount;
}
void GreeterModuleImpl::greetNotify(const std::string& name)
{
// Emit the event declared in logos_events:. When loaded by a host
// this reaches every subscriber; constructed outside a host it is a
// safe no-op.
greeted("Hello, " + name + "!");
}
int64_t GreeterModuleImpl::emitBlob(int64_t size)
{
// A deterministic blob the subscriber can check byte-for-byte.
// It deliberately contains 0x00 and bytes >= 0x80 — the values a
// text encoding would mangle.
std::vector<uint8_t> payload;
payload.reserve(static_cast<size_t>(size));
for (int64_t i = 0; i < size; ++i)
payload.push_back(static_cast<uint8_t>((i * 7 + 11) & 0xff));
blobReady("blob", payload);
return static_cast<int64_t>(payload.size());
}
- title: "Create the caller: orchestrator_module"
step: true
text: |
`orchestrator_module` declares `greeter_module` as a dependency. That one
line in `metadata.json` is what makes the builder run the SDK's code
generator over the greeter's exported interface and emit a typed
`modules().greeter_module` wrapper — with sync callers, async callers, and
event subscribers — that the orchestrator uses without any raw `LogosAPI`.
steps:
- title: "metadata.json — declare the dependency"
text: |
The `dependencies` entry must match `greeter_module`'s own
`metadata.json` `name`.
file:
path: orchestrator_module/metadata.json
language: json
content: |
{
"name": "orchestrator_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "A caller module: composes greeter_module via typed sync/async calls and an event subscription",
"main": "orchestrator_module_plugin",
"interface": "universal",
"dependencies": ["greeter_module"],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": []
}
}
}
- title: "CMakeLists.txt"
file:
path: orchestrator_module/CMakeLists.txt
language: cmake
content: |
cmake_minimum_required(VERSION 3.14)
project(OrchestratorModulePlugin LANGUAGES CXX)
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
include(cmake/LogosModule.cmake)
else()
message(FATAL_ERROR "LogosModule.cmake not found")
endif()
logos_module(
NAME orchestrator_module
SOURCES
src/orchestrator_module_impl.h
src/orchestrator_module_impl.cpp
)
- title: "flake.nix — add the dependency input"
text: |
Declare `greeter_module` as a flake input; the input name **must
match** the dependency name in `metadata.json`. The `path:` value is a
placeholder — we lock it to the real greeter checkout in the build step
with `--override-input` (Nix won't accept a relative `../` path written
directly into `flake.nix`).
file:
path: orchestrator_module/flake.nix
language: nix
content: |
{
description = "Orchestrator core module - calls greeter_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder{release}";
# The module this one depends on. Placeholder path — locked to the
# real checkout in the build step via --override-input.
greeter_module.url = "path:/path/to/your/greeter_module";
};
outputs = inputs@{ logos-module-builder, greeter_module, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
- title: "src/orchestrator_module_impl.h — the class"
text: |
Three composition paths, all through `modules().greeter_module`:
`greetThrough`/`greetReport` (sync), `startAsyncGreet`/`asyncGreeting`
(async), and `subscribeGreeted`/`lastGreetedEvent` (event).
file:
path: orchestrator_module/src/orchestrator_module_impl.h
language: cpp
content: |
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <logos_json.h> // LogosMap
#include <logos_module_context.h> // LogosModuleContext base + modules()
// A caller module. It does nothing on its own — it composes
// greeter_module through the typed modules().greeter_module wrapper the
// builder generates from the dependency declared in metadata.json.
class OrchestratorModuleImpl : public LogosModuleContext {
public:
OrchestratorModuleImpl() = default;
~OrchestratorModuleImpl() = default;
/// Calls greeter_module.greet(name) and returns its result verbatim.
std::string greetThrough(const std::string& name);
/// Composes several greeter_module calls into one map: a greeting,
/// an integer sum, and the greeter's current greet count.
LogosMap greetReport(const std::string& name, int64_t a, int64_t b);
/// Fires greeter_module.greetAsync(name) asynchronously and returns
/// "queued" immediately; the reply lands in a callback. Read it back
/// with asyncGreeting().
std::string startAsyncGreet(const std::string& name);
/// The greeting delivered by startAsyncGreet()'s callback, or empty
/// until it arrives.
std::string asyncGreeting() const;
/// Subscribes to greeter_module's `greeted` event with a typed
/// callback. Returns "ok" once registered.
std::string subscribeGreeted();
/// The last greeting captured by the `greeted` subscription, or
/// empty until the event fires.
std::string lastGreetedEvent() const;
/// Subscribes to greeter_module's `blobReady` event — the binary
/// one. Returns "ok" once registered.
std::string subscribeBlob();
/// How many bytes the `blobReady` subscription actually received,
/// or -1 until the event fires.
int64_t lastBlobSize() const;
/// A checksum over those bytes — proves the payload arrived
/// intact, not merely with the right length.
int64_t lastBlobChecksum() const;
private:
std::string m_asyncGreeting;
std::string m_lastGreetedEvent;
bool m_subscribed = false;
bool m_blobSubscribed = false;
int64_t m_lastBlobSize = -1;
int64_t m_lastBlobChecksum = -1;
};
- title: "src/orchestrator_module_impl.cpp — the implementation"
text: |
The `.cpp` includes the generated `logos_sdk.h` (which defines
`LogosModules`) — that's why the cross-module calls live here and not in
the header the generator parses. Each method drives `greeter_module`
through the generated wrapper: `.greet(...)` (sync), `.greetAsync(...,
cb)` (async), `.onGreeted(cb)` (event).
file:
path: orchestrator_module/src/orchestrator_module_impl.cpp
language: cpp
content: |
#include "orchestrator_module_impl.h"
// Generated at build time by logos-cpp-generator. Defines LogosModules
// with one std-typed accessor per metadata.json dependency — here
// greeter_module. Included only in the .cpp so the impl header the
// generator parses stays free of Qt and codegen types.
#include "logos_sdk.h"
std::string OrchestratorModuleImpl::greetThrough(const std::string& name)
{
// The simplest cross-module round-trip: one typed sync call.
return modules().greeter_module.greet(name);
}
LogosMap OrchestratorModuleImpl::greetReport(const std::string& name,
int64_t a, int64_t b)
{
// Three typed sync calls into greeter_module, composed into one map.
auto& greeter = modules().greeter_module;
LogosMap report;
report["greeting"] = greeter.greet(name);
report["sum"] = greeter.addInts(a, b);
report["greetCount"] = greeter.greetCount();
return report;
}
std::string OrchestratorModuleImpl::startAsyncGreet(const std::string& name)
{
// The generated async overload returns immediately; the reply is
// delivered to the callback on this module's event loop.
modules().greeter_module.greetAsync(name, [this](const std::string& g) {
m_asyncGreeting = g;
});
return "queued";
}
std::string OrchestratorModuleImpl::asyncGreeting() const
{
return m_asyncGreeting;
}
std::string OrchestratorModuleImpl::subscribeGreeted()
{
if (m_subscribed) return "ok";
// Typed subscriber generated from greeter_module's logos_events:
// greeted(const std::string&). The accessor is `on` + the
// capitalized event name.
m_subscribed = modules().greeter_module.onGreeted(
[this](const std::string& greeting) {
m_lastGreetedEvent = greeting;
});
return m_subscribed ? "ok" : "failed";
}
std::string OrchestratorModuleImpl::lastGreetedEvent() const
{
return m_lastGreetedEvent;
}
std::string OrchestratorModuleImpl::subscribeBlob()
{
if (m_blobSubscribed) return "ok";
// The binary event. The callback takes real bytes: the tagged
// wire form is encoded by the greeter's generated event body and
// decoded by this generated subscriber, so neither author writes
// any base64.
m_blobSubscribed = modules().greeter_module.onBlobReady(
[this](const std::string& label,
const std::vector<uint8_t>& payload) {
(void)label;
m_lastBlobSize = static_cast<int64_t>(payload.size());
int64_t sum = 0;
for (size_t i = 0; i < payload.size(); ++i)
sum += static_cast<int64_t>(payload[i])
* static_cast<int64_t>(i % 31 + 1);
m_lastBlobChecksum = sum;
});
return m_blobSubscribed ? "ok" : "failed";
}
int64_t OrchestratorModuleImpl::lastBlobSize() const
{
return m_lastBlobSize;
}
int64_t OrchestratorModuleImpl::lastBlobChecksum() const
{
return m_lastBlobChecksum;
}
- title: "Build both modules against this SDK"
step: true
text: |
Nix flakes only see files tracked by git, so initialise a repo in each
module first. Then build each module's `.lgx`, overriding `logos-cpp-sdk`
to the commit under test so the generated wrappers, plugin glue, and IPC
come from this SDK.
> Each override URL carries a `{release}` placeholder the doc-test runner
> expands to a concrete ref: locally that is this `logos-cpp-sdk`
> checkout's `HEAD` (see `run.sh`); in CI it is the commit being tested.
> With no pin it falls back to latest `master`.
steps:
- title: "Initialise git repos"
text: "The greeter is a dependency of the orchestrator, so both must be tracked."
run: |
(cd greeter_module && git init -q && git add -A)
(cd orchestrator_module && git init -q && git add -A)
check_file: "greeter_module/.git/HEAD"
- title: "Build the greeter's .lgx against this SDK"
text: |
The greeter has no module dependency, so only its builder's
`logos-cpp-sdk` needs overriding.
run: |
nix build 'path:./greeter_module#lgx' \
--override-input logos-module-builder 'github:logos-co/logos-module-builder{release}' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-o greeter-lgx
code_block: |
# From inside the greeter clone this is simply:
# nix build '.#lgx' --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk'
nix build 'path:./greeter_module#lgx' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-o greeter-lgx
post_text: "The greeter package is under `./greeter-lgx/`:"
extra_run:
run: "ls greeter-lgx/*.lgx"
- title: "Build the orchestrator's .lgx against this SDK"
text: |
The orchestrator pulls in `greeter_module` as a dependency, so we lock
that input to the local greeter checkout **and** override
`logos-cpp-sdk` in both the orchestrator's builder and the greeter's
builder — so the dependency wrapper the generator emits, and both
plugins, are built against one consistent SDK.
run: |
nix build 'path:./orchestrator_module#lgx' \
--override-input greeter_module 'path:./greeter_module' \
--override-input logos-module-builder 'github:logos-co/logos-module-builder{release}' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input greeter_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input greeter_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-o orchestrator-lgx
code_block: |
nix build 'path:./orchestrator_module#lgx' \
--override-input greeter_module 'path:./greeter_module' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input greeter_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input greeter_module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
-o orchestrator-lgx
post_text: "The orchestrator package is under `./orchestrator-lgx/`:"
extra_run:
run: "ls orchestrator-lgx/*.lgx"
- title: "Build the runtime and install both modules"
step: true
text: |
Build `logoscore` (against this SDK, the same way as the runtime doc-test)
and `lgpm`, then install both modules into a `./modules` directory the
daemon can scan.
steps:
- title: "Build logoscore against this SDK"
run: |
nix build 'github:logos-co/logos-logoscore-cli{release}' \
--override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder 'github:logos-co/logos-module-builder{release}' \
--override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk{release}' \
--override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--out-link ./logos
code_block: |
nix build 'github:logos-co/logos-logoscore-cli' \
--override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-liblogos/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-capability-module/logos-module-builder/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--override-input logos-capability-module/logos-module-builder/logos-test-framework/logos-qt-sdk/logos-lidl 'github:logos-co/logos-lidl' \
--out-link ./logos
check_file: "logos/bin/logoscore"
- title: "Build lgpm"
run: "nix build 'github:logos-co/logos-package-manager#cli' -o lgpm"
check_file: "lgpm/bin/lgpm"
- title: "Seed the modules directory with the capability module"
text: |
Loading a module goes through the host's capability layer, so the
modules directory needs the `capability_module` that ships with
`logoscore` (rebuilt against this SDK). Copy it across first.
run: |
mkdir -p modules
cp -RL ./logos/modules/. ./modules/
check_file: "modules/capability_module/manifest.json"
- title: "Install the greeter"
run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file greeter-lgx/*.lgx"
expect_contains:
- "Installed to:"
- title: "Install the orchestrator"
run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file orchestrator-lgx/*.lgx"
expect_contains:
- "Installed to:"
- title: "Confirm both modules are installed"
run: "./lgpm/bin/lgpm --modules-dir ./modules list"
expect_contains:
- "greeter_module"
- "orchestrator_module"
check_file: "modules/orchestrator_module/manifest.json"
- title: "Load both modules and drive the caller"
step: true
text: |
Start `logoscore` in daemon mode (`-D`) — it keeps each module's process
alive between `call` commands, so an event subscription registered by one
call is still active when a later call triggers it, and an async reply
lands before the call that reads it. Load **both** modules, then call the
orchestrator's methods — each one reaches across the process boundary into
the greeter.
steps:
- title: "Start the daemon"
run: "sh -c './logos/bin/logoscore -D -m ./modules > logs.txt 2>&1 &'"
code_block: "logoscore -D -m ./modules > logs.txt &"
- run: "sleep 3"
- title: "Load the greeter (the dependency first)"
run: "./logos/bin/logoscore load-module greeter_module"
code_block: "logoscore load-module greeter_module"
expect_contains:
- "greeter_module"
- title: "Load the orchestrator"
run: "./logos/bin/logoscore load-module orchestrator_module"
code_block: "logoscore load-module orchestrator_module"
expect_contains:
- "orchestrator_module"
- title: "Confirm both report loaded"
run: "./logos/bin/logoscore status"
code_block: "logoscore status"
expect_contains:
- "greeter_module"
- "orchestrator_module"
- '"status":"loaded"'
- title: "Synchronous cross-module call"
text: |
`greetThrough(name)` forwards straight to `greeter_module.greet(name)`
and returns the result — one typed sync call across the process
boundary:
run: "./logos/bin/logoscore call orchestrator_module greetThrough World"
code_block: "logoscore call orchestrator_module greetThrough World"
expect_contains:
- '"result":"Hello, World!"'
- title: "Compose several calls into one result"
text: |
`greetReport(name, a, b)` fans out to three greeter calls — `greet`,
`addInts`, and `greetCount` — and returns them as one map. The greeter
has now been greeted twice (once above, once here), so `greetCount` is
`2`:
run: "./logos/bin/logoscore call orchestrator_module greetReport Logos 3 5"
code_block: "logoscore call orchestrator_module greetReport Logos 3 5"
expect_contains:
- '"greeting":"Hello, Logos!"'
- '"sum":8'
- '"greetCount":2'
- title: "Asynchronous cross-module call"
text: |
`startAsyncGreet(name)` fires `greeter_module.greetAsync(name)` and
returns `"queued"` immediately. The reply arrives on the daemon's event
loop; the next call, `asyncGreeting()`, reads what the callback stashed:
run: "./logos/bin/logoscore call orchestrator_module startAsyncGreet Async"
code_block: "logoscore call orchestrator_module startAsyncGreet Async"
expect_contains:
- '"result":"queued"'
- run: "sleep 1"
- title: "Read the async reply"
run: "./logos/bin/logoscore call orchestrator_module asyncGreeting"
code_block: "logoscore call orchestrator_module asyncGreeting"
expect_contains:
- '"result":"Hello, Async!"'
- title: "Subscribe to the greeter's event"
text: |
`subscribeGreeted()` registers a typed callback on `greeter_module`'s
`greeted` event. Then `greeter_module.greetNotify(...)` makes the
greeter emit it, and `lastGreetedEvent()` reads what the subscription
captured. Because the daemon keeps both modules loaded, the event fires
between the calls:
run: "./logos/bin/logoscore call orchestrator_module subscribeGreeted"
code_block: "logoscore call orchestrator_module subscribeGreeted"
expect_contains:
- '"result":"ok"'
- title: "Trigger the event from the greeter"
run: "./logos/bin/logoscore call greeter_module greetNotify Events"
code_block: "logoscore call greeter_module greetNotify Events"
- run: "sleep 1"
- title: "Read the captured event payload"
run: "./logos/bin/logoscore call orchestrator_module lastGreetedEvent"
code_block: "logoscore call orchestrator_module lastGreetedEvent"
expect_contains:
- '"result":"Hello, Events!"'
- title: "Subscribe to the greeter's binary event"
text: |
The same flow, but the event carries a **byte string** (`bstr`) rather
than text. Binary payloads cannot ride in a JSON string — a NUL would
truncate them and any byte above 0x7f would be mangled — so they travel
in the canonical tagged form `{"_bytes": "<base64url>"}`. Both halves of
that are generated: the greeter's event body encodes, this subscriber
decodes, and neither author writes a line of base64.
run: "./logos/bin/logoscore call orchestrator_module subscribeBlob"
code_block: "logoscore call orchestrator_module subscribeBlob"
expect_contains:
- '"result":"ok"'
- title: "Emit 4096 bytes from the greeter"
text: "The greeter reports how many bytes it put on the wire."
run: "./logos/bin/logoscore call greeter_module emitBlob 4096"
code_block: "logoscore call greeter_module emitBlob 4096"
expect_contains:
- '"result":4096'
- run: "sleep 1"
- title: "The subscriber received every byte"
text: |
`lastBlobSize` is the length the subscription actually saw. This is the
assertion that pins [#99](https://github.com/logos-co/logos-cpp-sdk/issues/99),
where the generator dropped `bstr` event arguments: the greeter emitted
a full payload and the subscriber received `0` bytes.
run: "./logos/bin/logoscore call orchestrator_module lastBlobSize"
code_block: "logoscore call orchestrator_module lastBlobSize"
expect_contains:
- '"result":4096'
- title: "...and the bytes are the right bytes"
text: |
Length alone would not catch a corrupted payload — a wrong base64
alphabet or a botched tail group round-trips to the same size. The
checksum is computed over the received bytes and must match the blob the
greeter built.
run: "./logos/bin/logoscore call orchestrator_module lastBlobChecksum"
code_block: "logoscore call orchestrator_module lastBlobChecksum"
expect_contains:
- '"result":8354754'
- title: "Stop the daemon"
run: "./logos/bin/logoscore stop"
code_block: "logoscore stop"
- run: "sleep 2"
- title: "Confirm the daemon has stopped"
run: "./logos/bin/logoscore status || true"
code_block: "logoscore status"
expect_contains:
- '"status":"not_running"'
- title: "Recap"
text: |
| Composition path | In the orchestrator | Driven via `logoscore` |
| ---------------- | ------------------- | ---------------------- |
| Typed **sync** call | `greetThrough()` → `greeter.greet(...)` | `"Hello, World!"` |
| Composed sync calls | `greetReport()` → `greet` + `addInts` + `greetCount` | one map of three results |
| Typed **async** call | `startAsyncGreet()` → `greetAsync(..., cb)` | `queued`, then `"Hello, Async!"` |
| Typed **event** subscription | `subscribeGreeted()` → `onGreeted(cb)` | captured `"Hello, Events!"` |
| **Binary** event payload | `subscribeBlob()` → `onBlobReady(cb)` | all 4096 bytes, checksum intact |
Every path went through `modules().greeter_module`, the wrapper the SDK's
code generator emitted from the `greeter_module` dependency — and both
modules, the wrapper, and the runtime were built against the SDK commit
under test. A green run means inter-module composition still works on this
SDK, end to end.
The binary row is the one with teeth. Bytes are the only payload that
cannot ride in a JSON string, so they are the only one that needs an
encoder on the emitting side and a decoder on the receiving side — two
pieces of generated code that must agree exactly. When they did not
([#99](https://github.com/logos-co/logos-cpp-sdk/issues/99)), everything
above still passed: the module emitted a full payload, the transport
carried it, and the subscriber received zero bytes. Asserting on the
*length and the contents* of what actually arrived is what catches that.