Files
logos-tutorial/tests/tutorial-composing-modules.test.yaml
T
Dario Gabriel LipicarandClaude Opus 5 d96387128d test(doctests): lm publishes the LIDL contract vocabulary, not Qt names
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>
2026-08-21 20:32:35 -03:00

627 lines
36 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 23) 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)).