mirror of
https://github.com/logos-co/logos-tutorial.git
synced 2026-08-27 19:01:11 +00:00
A universal module's `getMethods()` comes from the cdylib backend's
`lidlInterfaceJson()` (logos-plugin-qt's glue forwards
`logos_module_get_methods` verbatim), and that now answers in the LIDL
contract spelling. `lm methods`, `lm events` and `logoscore module-info`
print those strings straight through, so every listing in Part 1 changed:
qlonglong add(qlonglong a, qlonglong b) -> int add(int a, int b)
QString libVersion() -> tstr libVersion()
void versionReady(QString version) -> void versionReady(tstr version)
Six `expect_contains` in tutorial-wrapping-c-library.test.yaml were pinned
to the Qt spellings and now fail.
HOW THIS WAS ALMOST MISSED, because the trap will recur. The hand-pinned
`outputs/tutorial-wrapping-c-library.md` already showed `int add(int a, int b)`
and `add(int,int)` — a stale snapshot from an earlier era that happened to
read as "already LIDL, nothing to do". CI runs the ASSERTIONS in
`tests/*.test.yaml`; it never diffs the outputs tree. Clearing a file by
reading `outputs/` proves nothing.
Every replacement string is derived mechanically rather than by hand: the
tutorial's own `src/calc_module_impl.h` + `metadata.json` were run through
`logos-cpp-generator --from-header --backend cdylib`, the emitted
`lidlInterfaceJson()` was parsed back into JSON, and that JSON was rendered
through logos-module's own printer (`cmd/main.cpp`) and logoscore's
(`src/client/output.cpp`). The displayed blocks in BOTH trees now compare
byte-identical to that render.
Two accuracy fixes fall out of doing that, both pre-existing drift in the
blocks being rewritten:
* the derived identity methods `name()` / `version()` DO appear in every
listing (nothing filters `derived` on the read side) and were missing
from the shown output;
* the `module-info` block said `libVersion() -> QString` and
`versionReady(version: QString)`.
The C++-type table gains a column. "On the wire (Qt)" conflated two
different questions; it is now "LIDL contract type" — what the module
publishes, what Step 5 prints, what a Rust or Nim binding sees — and "A Qt
consumer sees", which is only the C++/Qt caller's spelling.
Also here, same cause:
* tutorial-composing-modules and tutorial-interface-dependencies had the
same "shows up as QString ... the wire types the generated glue exposes"
prose. Their assertions are name-only, so they did not fail — but they
described the listing wrongly. `LogosMap` publishes as `{tstr: any}`,
verified by generating calc_aggregator's glue.
* logos-developer-guide.md's `lm methods --json` example was a
handwritten-Qt listing (`initLogos(LogosAPI*)`) presented as the general
case. It now shows both publishers and says which is which: a universal
module publishes its contract, a handwritten Qt plugin publishes what its
QMetaObject says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
627 lines
36 KiB
YAML
627 lines
36 KiB
YAML
name: "Tutorial: Composing Modules with the Module Context"
|
||
output: tutorial-composing-modules.md
|
||
project_name: logos-calc-aggregator-module
|
||
requires:
|
||
- tutorial-wrapping-c-library.test.yaml
|
||
release: ""
|
||
|
||
intro: |
|
||
This tutorial builds `calc_aggregator`, a **core module that depends on another module** (`calc_module` from [Part 1](tutorial-wrapping-c-library.md)). It does no arithmetic of its own — instead it *composes* `calc_module`'s primitives into a single call, and along the way showcases everything the SDK's `LogosModuleContext` base class gives a universal module. There is no UI: you drive the whole thing from `logoscore` on the command line.
|
||
|
||
what_you_build: |
|
||
A `calc_aggregator` core module that, through the `LogosModuleContext` base class:
|
||
|
||
- reads the three host-injected properties — `modulePath()`, `instanceId()`, `instancePersistencePath()`
|
||
- persists state in its per-instance data directory (a run counter that survives restarts), wired up in the `onContextReady()` hook
|
||
- calls `calc_module` with the generated, type-safe `modules().calc_module` wrappers — **synchronously** (five calls composed into one `computeReport`) and **asynchronously** (`fibonacciAsync` with a callback)
|
||
- subscribes to `calc_module`'s `versionReady` **event** with a typed callback
|
||
|
||
No Qt, no `LogosAPI`, no plugin boilerplate — one plain C++ class, exactly like Part 1.
|
||
|
||
what_you_learn:
|
||
- How one module declares another as a dependency (`metadata.json` + `flake.nix` input)
|
||
- How `LogosModuleContext` exposes `modulePath` / `instanceId` / `instancePersistencePath` to a universal module
|
||
- How to use the per-instance persistence directory for durable state, set up in `onContextReady()`
|
||
- How `modules().<dep>` gives you typed **sync** and **async** callers — no raw `LogosAPI`, no `QVariant`
|
||
- How to subscribe to another module's `logos_events:` with a typed callback
|
||
- How to load two modules in `logoscore` and chain calls to observe events and async replies
|
||
|
||
prerequisites:
|
||
- "Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` whose shared library is built (`libcalc.so`/`.dylib` in `logos-calc-module/lib/`). This tutorial only needs `calc_module`; the UI tutorials (Parts 2–3) are not required."
|
||
- Nix with flakes enabled
|
||
- Basic familiarity with C++
|
||
|
||
sections:
|
||
# ── Step 1: Scaffold ────────────────────────────────────────────────────────
|
||
- title: "Scaffold the Module Project"
|
||
step: true
|
||
text: |
|
||
Create a new directory and initialise it from the minimal module template:
|
||
|
||
`mkdir logos-calc-aggregator-module && cd logos-calc-aggregator-module`
|
||
steps:
|
||
- title: "Create the project from the template"
|
||
run: "nix flake init -t github:logos-co/logos-module-builder{release}"
|
||
code_block: |
|
||
nix flake init -t github:logos-co/logos-module-builder{release}
|
||
post_text: |
|
||
This scaffolds a `flake.nix`, `metadata.json`, `CMakeLists.txt`, and a `src/` directory pre-wired for `logos-module-builder`. As in Part 1 we use the newer **pure-C++ (`interface: universal`) pattern**, so we replace the template's example `src/` files with a single plain `*_impl.h` / `*_impl.cpp` class.
|
||
|
||
- title: "Remove the template's example sources"
|
||
text: |
|
||
The minimal template ships an example Qt plugin (`minimal_*`). Delete those — this tutorial supplies its own pure-C++ `src/` files:
|
||
run: "rm -f src/minimal_interface.h src/minimal_plugin.h src/minimal_plugin.cpp"
|
||
|
||
# ── Step 2: Configure the module ────────────────────────────────────────────
|
||
- title: "Configure the Module"
|
||
step: true
|
||
text: |
|
||
Three small config files declare the module, its dependency on `calc_module`, and how to build it.
|
||
steps:
|
||
- title: "`metadata.json` — declare the dependency"
|
||
text: |
|
||
The one field that matters here is `dependencies`: listing `calc_module` tells the builder to read `calc_module`'s published LIDL interface contract and generate a typed wrapper for it — without building `calc_module` itself. The dependency name **must match** `calc_module`'s own `metadata.json` `name`.
|
||
file:
|
||
path: metadata.json
|
||
language: json
|
||
content: |
|
||
{
|
||
"name": "calc_aggregator",
|
||
"version": "1.0.0",
|
||
"type": "core",
|
||
"category": "general",
|
||
"description": "Composes calc_module and showcases LogosModuleContext",
|
||
"main": "calc_aggregator_plugin",
|
||
"interface": "universal",
|
||
"dependencies": ["calc_module"],
|
||
|
||
"nix": {
|
||
"packages": {
|
||
"build": [],
|
||
"runtime": []
|
||
},
|
||
"external_libraries": [],
|
||
"cmake": {
|
||
"find_packages": [],
|
||
"extra_sources": [],
|
||
"extra_include_dirs": [],
|
||
"extra_link_libraries": []
|
||
}
|
||
}
|
||
}
|
||
post_text: |
|
||
| Field | What it does |
|
||
| -------------- | ------------------------------------------------------------------------------------------------------------- |
|
||
| `interface` | `"universal"` — you write one plain C++ class; the builder generates the Qt plugin glue |
|
||
| `dependencies` | `["calc_module"]` — the builder generates `modules().calc_module`, a typed wrapper with sync/async/event APIs |
|
||
|
||
Unlike Part 1, there is no `external_libraries` entry — this module wraps no C library, it depends on another **module**.
|
||
|
||
- title: "`CMakeLists.txt` — list your sources"
|
||
text: |
|
||
For a universal module you list only your plain C++ files. The generated dependency glue is compiled automatically.
|
||
file:
|
||
path: CMakeLists.txt
|
||
language: cmake
|
||
content: |
|
||
cmake_minimum_required(VERSION 3.14)
|
||
project(CalcAggregatorPlugin LANGUAGES CXX)
|
||
|
||
# Include the Logos Module CMake helper (provided by logos-module-builder)
|
||
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 calc_aggregator
|
||
SOURCES
|
||
src/calc_aggregator_impl.h
|
||
src/calc_aggregator_impl.cpp
|
||
)
|
||
post_text: |
|
||
`NAME` must match `name` in `metadata.json` (`calc_aggregator`). No `EXTERNAL_LIBS` here — the only dependency is another module, resolved via `metadata.json` + `flake.nix`, not CMake.
|
||
|
||
- title: "`flake.nix` — add the dependency input"
|
||
text: |
|
||
Declare `calc_module` as a flake input. The input attribute name **must match** the dependency name in `metadata.json`. The `path:/path/to/your/calc_module` value is a placeholder — you lock it to your real Part 1 checkout in the build step with `--override-input` (Nix won't accept a relative `../` path written directly into `flake.nix`).
|
||
file:
|
||
path: flake.nix
|
||
language: nix
|
||
content: |
|
||
{
|
||
description = "Aggregator core module - composes calc_module and showcases LogosModuleContext";
|
||
|
||
inputs = {
|
||
logos-module-builder.url = "github:logos-co/logos-module-builder{release}";
|
||
|
||
# The module this one depends on. Placeholder path — locked to your
|
||
# real checkout in the build step via `--override-input`.
|
||
calc_module.url = "path:/path/to/your/calc_module";
|
||
};
|
||
|
||
outputs = inputs@{ logos-module-builder, calc_module, ... }:
|
||
logos-module-builder.lib.mkLogosModule {
|
||
src = ./.;
|
||
configFile = ./metadata.json;
|
||
flakeInputs = inputs;
|
||
};
|
||
}
|
||
post_text: |
|
||
`flakeInputs = inputs` hands every input (including `calc_module`) to the builder, which resolves the `calc_module` dependency declared in `metadata.json` and runs `logos-cpp-generator` to emit the typed wrapper.
|
||
|
||
# ── Step 3: Write the module ────────────────────────────────────────────────
|
||
- title: "Write the Module Class"
|
||
step: true
|
||
text: |
|
||
The whole module is one plain C++ class that inherits `LogosModuleContext`. Inheriting that base is what unlocks the context getters (`modulePath()` / `instanceId()` / `instancePersistencePath()`), the `onContextReady()` hook, and `modules()` — typed access to declared dependencies. No Qt anywhere.
|
||
steps:
|
||
- title: "`src/calc_aggregator_impl.h` — the class"
|
||
text: |
|
||
Every `public` method becomes callable over IPC. The methods fall into four groups: the context getters, the persistence demo, the sync/async composition of `calc_module`, and the event subscription.
|
||
file:
|
||
path: src/calc_aggregator_impl.h
|
||
language: cpp
|
||
content: |
|
||
#pragma once
|
||
|
||
#include <cstdint>
|
||
#include <string>
|
||
|
||
#include <logos_json.h> // LogosMap (QVariantMap on the wire)
|
||
#include <logos_module_context.h> // LogosModuleContext base class
|
||
|
||
// A core module that depends on calc_module. It does no arithmetic of
|
||
// its own — it *composes* calc_module's primitives and showcases what
|
||
// the SDK's LogosModuleContext base class gives a universal module:
|
||
//
|
||
// • modulePath() — where the plugin was loaded from
|
||
// • instanceId() — host-assigned, stable per persistence dir
|
||
// • instancePersistencePath() — per-instance writable data directory
|
||
// • onContextReady() — one-time setup hook
|
||
// • modules() — typed access to declared dependencies
|
||
// (sync callers, async callers, events)
|
||
//
|
||
// Because metadata.json sets "interface": "universal", the builder
|
||
// generates the Qt plugin wrapper from this plain class.
|
||
class CalcAggregatorImpl : public LogosModuleContext {
|
||
public:
|
||
CalcAggregatorImpl() = default;
|
||
~CalcAggregatorImpl() = default;
|
||
|
||
// ── The three host-injected context properties ─────────────────
|
||
|
||
/// Directory the plugin file was loaded from (modulePath()).
|
||
std::string moduleDir() const;
|
||
|
||
/// Host-assigned instance ID (instanceId()).
|
||
std::string instanceID() const;
|
||
|
||
/// True iff the host populated a non-empty instance ID. A bool
|
||
/// return distinguishes "host wired it" from the empty-string
|
||
/// default a plain string getter can't tell apart over the CLI.
|
||
bool hasInstanceID() const;
|
||
|
||
/// Per-instance writable data directory (instancePersistencePath()).
|
||
std::string persistenceDir() const;
|
||
|
||
/// Increments a counter stored under persistenceDir() and returns
|
||
/// the new value. The persistence dir is host-owned and durable, so
|
||
/// the count keeps climbing across restarts — it is loaded back in
|
||
/// onContextReady().
|
||
int64_t bumpRunCount();
|
||
|
||
// ── Compose calc_module: five sync calls into one result ───────
|
||
|
||
/// Runs add / multiply / factorial / fibonacci / libVersion on
|
||
/// calc_module and returns them as a single map. One call here
|
||
/// fans out to five typed, synchronous cross-module calls.
|
||
LogosMap computeReport(int64_t a, int64_t b, int64_t n);
|
||
|
||
// ── Compose calc_module: an async call ─────────────────────────
|
||
|
||
/// Fires calc_module.fibonacci(n) *asynchronously* and returns
|
||
/// right away ("queued"). The reply lands later in a callback that
|
||
/// stashes it; read it back with asyncResult().
|
||
std::string startAsyncFibonacci(int64_t n);
|
||
|
||
/// The most recent value delivered by startAsyncFibonacci()'s
|
||
/// callback, or -1 if none has arrived yet.
|
||
int64_t asyncResult() const;
|
||
|
||
// ── Subscribe to a calc_module event ───────────────────────────
|
||
|
||
/// Subscribes to calc_module's `versionReady` event with a typed
|
||
/// callback. Returns "ok" once registered. Trigger it by calling
|
||
/// calc_module.libVersionNotify().
|
||
std::string subscribeVersion();
|
||
|
||
/// The last version string delivered by the versionReady
|
||
/// subscription, or empty until one fires.
|
||
std::string lastVersionEvent() const;
|
||
|
||
protected:
|
||
// One-time hook the framework fires once the context getters above
|
||
// are populated, before any method dispatch — the canonical place
|
||
// for setup that needs the persistence path.
|
||
void onContextReady() override;
|
||
|
||
private:
|
||
int64_t m_runCount = 0;
|
||
int64_t m_asyncResult = -1;
|
||
std::string m_lastVersionEvent;
|
||
bool m_subscribed = false;
|
||
};
|
||
post_text: |
|
||
A few things to notice:
|
||
|
||
- The class inherits **`LogosModuleContext`** — that's the opt-in that gives it the context getters and `modules()`.
|
||
- `onContextReady()` is `protected` (an override of the base hook), so it is **not** exposed over IPC — only the `public` methods are.
|
||
- `hasInstanceID()` returns `bool` on purpose: the CLI prints a `Result:` line for any string (even empty), so a boolean is the unambiguous way to assert "the host populated the ID".
|
||
|
||
- title: "`src/calc_aggregator_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 group of methods maps one-to-one onto the bullets in the class comment.
|
||
file:
|
||
path: src/calc_aggregator_impl.cpp
|
||
language: cpp
|
||
content: |
|
||
#include "calc_aggregator_impl.h"
|
||
|
||
#include <fstream>
|
||
|
||
// Generated at build time by logos-cpp-generator. Defines `LogosModules`
|
||
// with one std-typed accessor per metadata.json dependency — here
|
||
// `calc_module`. Included only in the .cpp so the impl header the
|
||
// generator parses stays free of Qt and codegen types.
|
||
#include "logos_sdk.h"
|
||
|
||
namespace {
|
||
// The run-count file lives inside the host-provisioned persistence dir.
|
||
// An empty dir means the module was constructed outside a host (e.g. a
|
||
// unit test) — treat that as "nothing to persist".
|
||
std::string runCountPath(const std::string& dir) {
|
||
return dir.empty() ? std::string() : dir + "/runcount.txt";
|
||
}
|
||
} // namespace
|
||
|
||
void CalcAggregatorImpl::onContextReady() {
|
||
// The three context getters are populated now. Load any previously
|
||
// persisted run count so bumpRunCount() continues across restarts.
|
||
const std::string path = runCountPath(instancePersistencePath());
|
||
if (path.empty()) return;
|
||
std::ifstream in(path);
|
||
if (in) in >> m_runCount;
|
||
}
|
||
|
||
// ── Context getters — thin pass-throughs to the SDK base class ──────
|
||
|
||
std::string CalcAggregatorImpl::moduleDir() const {
|
||
return modulePath();
|
||
}
|
||
|
||
std::string CalcAggregatorImpl::instanceID() const {
|
||
return instanceId();
|
||
}
|
||
|
||
bool CalcAggregatorImpl::hasInstanceID() const {
|
||
return !instanceId().empty();
|
||
}
|
||
|
||
std::string CalcAggregatorImpl::persistenceDir() const {
|
||
return instancePersistencePath();
|
||
}
|
||
|
||
int64_t CalcAggregatorImpl::bumpRunCount() {
|
||
++m_runCount;
|
||
const std::string path = runCountPath(instancePersistencePath());
|
||
if (!path.empty()) {
|
||
std::ofstream out(path, std::ios::trunc);
|
||
out << m_runCount;
|
||
}
|
||
return m_runCount;
|
||
}
|
||
|
||
// ── Sync composition: five calls into one map ───────────────────────
|
||
|
||
LogosMap CalcAggregatorImpl::computeReport(int64_t a, int64_t b, int64_t n) {
|
||
// modules().calc_module is the generated, std-typed wrapper for the
|
||
// `calc_module` dependency — no raw LogosAPI, no QVariant. Five
|
||
// synchronous calls, composed into one map the caller gets back.
|
||
auto& calc = modules().calc_module;
|
||
LogosMap report;
|
||
report["sum"] = calc.add(a, b);
|
||
report["product"] = calc.multiply(a, b);
|
||
report["factorial"] = calc.factorial(n);
|
||
report["fibonacci"] = calc.fibonacci(n);
|
||
report["libVersion"] = calc.libVersion();
|
||
return report;
|
||
}
|
||
|
||
// ── Async composition: fire now, read the reply later ───────────────
|
||
|
||
std::string CalcAggregatorImpl::startAsyncFibonacci(int64_t n) {
|
||
// The generated async overload is `<method>Async(args...,
|
||
// callback, timeout = Timeout())`. It returns immediately; the
|
||
// reply is delivered to the callback on this module's event loop.
|
||
modules().calc_module.fibonacciAsync(n, [this](int64_t value) {
|
||
m_asyncResult = value;
|
||
});
|
||
return "queued";
|
||
}
|
||
|
||
int64_t CalcAggregatorImpl::asyncResult() const {
|
||
return m_asyncResult;
|
||
}
|
||
|
||
// ── Event subscription on a dependency ──────────────────────────────
|
||
|
||
std::string CalcAggregatorImpl::subscribeVersion() {
|
||
if (m_subscribed) return "ok";
|
||
// Typed subscriber generated from calc_module's `logos_events:`
|
||
// versionReady(const std::string&). The accessor is `on` + the
|
||
// capitalized event name; the callback's arg types match the event.
|
||
m_subscribed = modules().calc_module.onVersionReady(
|
||
[this](const std::string& version) {
|
||
m_lastVersionEvent = version;
|
||
});
|
||
return m_subscribed ? "ok" : "failed";
|
||
}
|
||
|
||
std::string CalcAggregatorImpl::lastVersionEvent() const {
|
||
return m_lastVersionEvent;
|
||
}
|
||
post_text: |
|
||
That's the entire module. The three capabilities the SDK base class enables are all here:
|
||
|
||
1. **Context properties** — `moduleDir()`, `instanceID()`, `persistenceDir()` just return the base getters; `bumpRunCount()` + `onContextReady()` show the persistence dir used for real, durable state.
|
||
2. **Typed dependency calls** — `computeReport()` uses the **sync** wrappers (`calc.add(...)`, …); `startAsyncFibonacci()` uses the **async** wrapper (`fibonacciAsync(..., callback)`).
|
||
3. **Typed event subscription** — `subscribeVersion()` registers a callback on `calc_module`'s `versionReady` event via the generated `onVersionReady(...)` accessor.
|
||
|
||
# ── Step 4: Build ───────────────────────────────────────────────────────────
|
||
- title: "Build the Module"
|
||
step: true
|
||
steps:
|
||
- title: "Add a `.gitignore` and init the repo"
|
||
text: |
|
||
Nix flakes require a git repository. Exclude build artifacts first:
|
||
file:
|
||
path: .gitignore
|
||
language: text
|
||
content: |
|
||
# Nix build output
|
||
result
|
||
result-*
|
||
|
||
# CMake build directory
|
||
build/
|
||
- text: "Initialise the repo and stage the files:"
|
||
run: "git init && git add -A"
|
||
|
||
- title: "Make sure `calc_module` is built"
|
||
text: |
|
||
The dependency must be built with its shared library present in `lib/` (from [Part 1](tutorial-wrapping-c-library.md#15-build-the-shared-library)). Verify it:
|
||
run: "ls ../logos-calc-module/lib/libcalc.{ext}"
|
||
code_block: |
|
||
ls ../logos-calc-module/lib/libcalc.so # Linux
|
||
ls ../logos-calc-module/lib/libcalc.dylib # macOS
|
||
post_text: "If it is missing, build it (as in Part 1, Step 1.5):"
|
||
extra_run:
|
||
run: "test -f ../logos-calc-module/lib/libcalc.{ext} || (cd ../logos-calc-module/lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd -)"
|
||
code_block: |
|
||
cd ../logos-calc-module/lib
|
||
gcc -shared -fPIC -o libcalc.so libcalc.c # Linux
|
||
# gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS
|
||
cd -
|
||
|
||
- title: "Lock the dependency and build"
|
||
text: |
|
||
Lock `calc_module` to your local Part 1 checkout. `--override-input` resolves `../logos-calc-module` to an absolute path and records it in `flake.lock`, replacing the placeholder in `flake.nix`:
|
||
run: "nix flake update --override-input calc_module path:../logos-calc-module"
|
||
code_block: |
|
||
nix flake update --override-input calc_module path:../logos-calc-module
|
||
- run: "git add flake.lock"
|
||
post_text: |
|
||
Now build the full package. For a universal module with a dependency, this is where `logos-cpp-generator` runs over both `src/calc_aggregator_impl.h` and `calc_module`'s published LIDL contract, emitting the plugin glue **and** the typed `modules().calc_module` wrapper under `generated_code/` — note `calc_module`'s own plugin is not built here, only its LIDL is read:
|
||
- run: "nix build"
|
||
|
||
- title: "Check the output"
|
||
run: "ls -la result/lib/"
|
||
post_text: |
|
||
You should see your plugin (extension depends on platform):
|
||
|
||
```
|
||
calc_aggregator_plugin.so # Linux
|
||
calc_aggregator_plugin.dylib # macOS
|
||
```
|
||
- check_file: "result/lib/calc_aggregator_plugin.{ext}"
|
||
|
||
# ── Step 5: Inspect ─────────────────────────────────────────────────────────
|
||
- title: "Inspect the Module"
|
||
step: true
|
||
text: |
|
||
Use `lm` to confirm the dependency and the public API made it into the binary.
|
||
steps:
|
||
- title: "Build `lm`"
|
||
run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm"
|
||
|
||
- title: "View metadata — note the dependency"
|
||
run: "./lm/bin/lm metadata result/lib/calc_aggregator_plugin.{ext}"
|
||
code_block: |
|
||
./lm/bin/lm metadata result/lib/calc_aggregator_plugin.so # Linux
|
||
./lm/bin/lm metadata result/lib/calc_aggregator_plugin.dylib # macOS
|
||
expect_contains:
|
||
- "Name: calc_aggregator"
|
||
- "calc_module"
|
||
post_text: |
|
||
```
|
||
Plugin Metadata:
|
||
================
|
||
Name: calc_aggregator
|
||
Version: 1.0.0
|
||
Description: Composes calc_module and showcases LogosModuleContext
|
||
Author:
|
||
Type: core
|
||
Dependencies: calc_module
|
||
```
|
||
|
||
`Dependencies: calc_module` confirms the link the builder used to generate the typed wrapper.
|
||
|
||
- title: "List methods"
|
||
run: "./lm/bin/lm methods result/lib/calc_aggregator_plugin.{ext}"
|
||
code_block: |
|
||
./lm/bin/lm methods result/lib/calc_aggregator_plugin.so # Linux
|
||
./lm/bin/lm methods result/lib/calc_aggregator_plugin.dylib # macOS
|
||
expect_contains:
|
||
- "computeReport"
|
||
- "startAsyncFibonacci"
|
||
- "subscribeVersion"
|
||
- "bumpRunCount"
|
||
- "persistenceDir"
|
||
post_text: |
|
||
Every `public` method on the impl is here, published in the **LIDL contract** vocabulary rather than in C++ or Qt names: `int64_t` shows up as `int`, `std::string` as `tstr`, and `LogosMap` (from `computeReport`) as `{tstr: any}`. `lm` is reporting what the module says about itself, and what a module publishes is its contract — the same words the generated `.lidl` uses, and the same words a Rust or Nim module implementing this contract would answer with.
|
||
|
||
# ── Step 6: Run it with logoscore ───────────────────────────────────────────
|
||
- title: "Run it with `logoscore`"
|
||
step: true
|
||
text: |
|
||
Now the payoff: run `calc_aggregator` **and** its `calc_module` dependency under `logoscore` and exercise every capability. We use the `logoscore` **daemon** (`-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. (This is the same daemon flow as [Part 1](tutorial-wrapping-c-library.md#step-6-test-with-logoscore).)
|
||
steps:
|
||
- title: "Build the runtime and package both modules"
|
||
text: |
|
||
Build `logoscore` and the package manager, then install **both** modules into a `modules/` directory `logoscore` can scan. The aggregator comes from this project; `calc_module` from your Part 1 checkout:
|
||
run: "nix build 'github:logos-co/logos-logoscore-cli{release}' --out-link ./logos"
|
||
- run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm"
|
||
- run: "mkdir -p modules"
|
||
- title: "Install calc_aggregator"
|
||
run: "nix build '.#lgx' --out-link result-aggregator-lgx && ./pm/bin/lgpm --modules-dir ./modules install --file result-aggregator-lgx/*.lgx"
|
||
code_block: |
|
||
nix build '.#lgx' --out-link result-aggregator-lgx
|
||
./pm/bin/lgpm --modules-dir ./modules install --file result-aggregator-lgx/*.lgx
|
||
- title: "Install calc_module (the dependency)"
|
||
run: "nix build 'path:../logos-calc-module#lgx' --out-link result-calc-lgx && ./pm/bin/lgpm --modules-dir ./modules install --file result-calc-lgx/*.lgx"
|
||
code_block: |
|
||
nix build 'path:../logos-calc-module#lgx' --out-link result-calc-lgx
|
||
./pm/bin/lgpm --modules-dir ./modules install --file result-calc-lgx/*.lgx
|
||
post_text: |
|
||
`modules/` now holds `calc_aggregator/` and `calc_module/`, each with its plugin, libraries, and `manifest.json`.
|
||
|
||
- title: "Create a persistence directory and start the daemon"
|
||
text: |
|
||
The host only provisions a per-instance persistence path when you pass `--persistence-path`. Create a directory for it — we reuse the **same** directory across restarts so the instance ID (and therefore the persisted state) is stable:
|
||
run: "mkdir -p calc-data"
|
||
post_text: "Start `logoscore` as a background daemon, pointed at the modules directory and the persistence directory:"
|
||
- run: "./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data &"
|
||
- run: "sleep 4"
|
||
post_text: "Load both modules. The daemon keeps each module's process alive between `call` commands, which is what lets an event subscription (or an async reply) survive from one call to the next:"
|
||
- run: "./logos/bin/logoscore load-module calc_module"
|
||
- run: "./logos/bin/logoscore load-module calc_aggregator"
|
||
|
||
- title: "Read the context properties"
|
||
text: |
|
||
`moduleDir()` / `hasInstanceID()` / `persistenceDir()` return the values the host stamped onto the module. We can't predict the absolute path, but `moduleDir()` must contain the module name:
|
||
run: "./logos/bin/logoscore call calc_aggregator moduleDir"
|
||
expect_contains:
|
||
- "calc_aggregator"
|
||
post_text: "`hasInstanceID()` returns a bool, so a non-empty instance ID shows as `\"result\":true` — an unambiguous signal the host populated `instanceId()` (a plain string getter would read as empty either way):"
|
||
- run: "./logos/bin/logoscore call calc_aggregator hasInstanceID"
|
||
expect_contains:
|
||
- '"result":true'
|
||
- run: "./logos/bin/logoscore call calc_aggregator persistenceDir"
|
||
expect_contains:
|
||
- "calc-data"
|
||
- "calc_aggregator"
|
||
post_text: |
|
||
- `moduleDir()` → the directory the plugin loaded from (contains `calc_aggregator`)
|
||
- `hasInstanceID()` → `"result":true` — the host populated `instanceId()`
|
||
- `persistenceDir()` → a path under your `calc-data/` directory, namespaced by module name and instance ID
|
||
|
||
- title: "Compose calc_module synchronously"
|
||
text: |
|
||
`computeReport(a, b, n)` fans out to five typed `calc_module` calls and returns them as one map. With `a=3, b=5, n=10`:
|
||
run: "./logos/bin/logoscore call calc_aggregator computeReport 3 5 10"
|
||
expect_contains:
|
||
- '"sum":8'
|
||
- '"product":15'
|
||
- '"factorial":3628800'
|
||
- '"fibonacci":55'
|
||
- '"libVersion":"1.0.0"'
|
||
post_text: |
|
||
One call, five composed results:
|
||
|
||
```json
|
||
{"method":"computeReport","module":"calc_aggregator","result":{"factorial":3628800,"fibonacci":55,"libVersion":"1.0.0","product":15,"sum":8},"status":"ok"}
|
||
```
|
||
|
||
`sum = 3+5`, `product = 3*5`, `factorial = 10!`, `fibonacci = fib(10)`, and `libVersion` read straight from `calc_module` — all through the generated `modules().calc_module` sync wrappers.
|
||
|
||
- title: "Compose calc_module asynchronously"
|
||
text: |
|
||
`startAsyncFibonacci(n)` fires `calc_module.fibonacciAsync(n)` and returns `"queued"` immediately. The reply arrives on the daemon's event loop; the next call, `asyncResult()`, reads what the callback stashed. With `n=20`, `fib(20) = 6765`:
|
||
run: "./logos/bin/logoscore call calc_aggregator startAsyncFibonacci 20"
|
||
expect_contains:
|
||
- '"result":"queued"'
|
||
- run: "sleep 1"
|
||
- run: "./logos/bin/logoscore call calc_aggregator asyncResult"
|
||
expect_contains:
|
||
- '"result":6765'
|
||
post_text: |
|
||
`startAsyncFibonacci` returned before the answer existed; by the time `asyncResult()` runs, the async callback has fired and stored `6765`. That's the typed **async** caller — same wrapper, `<method>Async(..., callback)`.
|
||
|
||
- title: "Subscribe to a calc_module event"
|
||
text: |
|
||
The typed event subscription. `subscribeVersion()` registers the callback, `calc_module.libVersionNotify()` makes `calc_module` emit its `versionReady` event, and `lastVersionEvent()` reads what the subscription captured. Because the daemon keeps both modules loaded, the event fires between the calls:
|
||
run: "./logos/bin/logoscore call calc_aggregator subscribeVersion"
|
||
expect_contains:
|
||
- '"result":"ok"'
|
||
- run: "./logos/bin/logoscore call calc_module libVersionNotify"
|
||
- run: "sleep 1"
|
||
- run: "./logos/bin/logoscore call calc_aggregator lastVersionEvent"
|
||
expect_contains:
|
||
- '"result":"1.0.0"'
|
||
post_text: |
|
||
`subscribeVersion()` returned `ok`; the event fired in between; `lastVersionEvent()` returned `1.0.0` — the payload `calc_module` emitted, delivered to the typed callback you registered with `modules().calc_module.onVersionReady(...)`.
|
||
|
||
- title: "Persist state across a restart"
|
||
text: |
|
||
`bumpRunCount()` increments a counter saved in the persistence directory. Call it twice — `1`, then `2`:
|
||
run: "./logos/bin/logoscore call calc_aggregator bumpRunCount"
|
||
expect_contains:
|
||
- '"result":1'
|
||
- run: "./logos/bin/logoscore call calc_aggregator bumpRunCount"
|
||
expect_contains:
|
||
- '"result":2'
|
||
post_text: "Now stop the daemon and start a **brand-new** one against the same persistence directory. `onContextReady()` loads the persisted `2` from disk, so the next bump is `3`:"
|
||
- run: "./logos/bin/logoscore stop"
|
||
- run: "sleep 2"
|
||
- run: "./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data &"
|
||
- run: "sleep 4"
|
||
- run: "./logos/bin/logoscore load-module calc_aggregator"
|
||
- run: "./logos/bin/logoscore call calc_aggregator bumpRunCount"
|
||
expect_contains:
|
||
- '"result":3'
|
||
post_text: |
|
||
The count survived a full process restart — proof the persistence directory is host-owned and durable, and that `onContextReady()` is the right place to rehydrate per-instance state.
|
||
- run: "./logos/bin/logoscore stop"
|
||
post_text: |
|
||
That completes the tour: context properties, durable persistence, sync **and** async typed dependency calls, and a typed event subscription — every capability of `LogosModuleContext`, driven entirely from `logoscore`.
|
||
|
||
# ── Recap (prose only) ──────────────────────────────────────────────────────
|
||
- title: "Recap"
|
||
text: |
|
||
| Capability | In the code | Seen via `logoscore` |
|
||
| -------------------------------- | -------------------------------------------------------- | --------------------------------------------------- |
|
||
| `modulePath()` | `moduleDir()` | path contains `calc_aggregator` |
|
||
| `instanceId()` | `instanceID()` / `hasInstanceID()` | `"result":true` |
|
||
| `instancePersistencePath()` | `persistenceDir()` + `bumpRunCount()` + `onContextReady` | counter climbs `1 → 2 → 3` across a restart |
|
||
| Typed **sync** dependency call | `computeReport()` → `calc.add(...)`, … | one map of five composed results |
|
||
| Typed **async** dependency call | `startAsyncFibonacci()` → `fibonacciAsync(..., cb)` | `queued`, then `6765` |
|
||
| Typed **event** subscription | `subscribeVersion()` → `onVersionReady(cb)` | captured payload `1.0.0` after the event fires |
|
||
|
||
Everything flowed through `modules().calc_module`, the wrapper the builder generated from the `calc_module` dependency — no raw `LogosAPI`, no `QVariant`, no Qt in your code.
|
||
|
||
**Next:** give this module a UI by following [Part 2 (QML-only)](tutorial-qml-ui-app.md) or [Part 3 (C++ backend)](tutorial-cpp-ui-app.md), or package it for distribution with `nix build '.#lgx-portable'` (see [Part 1 — Package for Distribution](tutorial-wrapping-c-library.md#package-for-distribution-optional)).
|