Files
logos-tutorial/outputs/tutorial-composing-modules.md
Dario LipicarandClaude Opus 5 24d8aea7d3 docs: the widened Qt consumer surface (#84)
* 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>

* docs(guide): the plugin path needs the module's contract, not just its plugin

`logos-cpp-generator <plugin> [--module-only]` is documented here as a way to
generate a module's consumer wrapper, and the examples pass only the plugin.
That is now a trap for any module built with `interface: "universal"` or
`"cdylib"`: its published `getMethods()` answers in the LIDL contract
vocabulary — the same change d963871 pinned in the `lm` listings — while the
wrapper emitter reads Qt type names and falls back to QVariant / LogosMap for
anything else. The wrapper would compile and have lost every type.

The generator now takes the METHODS from the `.lidl` contract named by
`--events-from` (the flag keeps its name; the file always was the whole
contract), and REFUSES a LIDL-spelled listing when no contract was given rather
than emitting the untyped wrapper. Nix builds already pass the flag —
buildHeaders.nix finds `<module>/share/logos/<name>.lidl` — so only hand-run
invocations, which is what this section documents, had to change.

Both examples gain the flag, a second example shows the handcrafted-Qt case
that legitimately omits it, and the synopsis in the CLI reference lists it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:04:54 -03:00

27 KiB
Raw Permalink Blame History

Tutorial: Composing Modules with the Module Context

This tutorial builds calc_aggregator, a core module that depends on another module (calc_module from Part 1). 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'll 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'll 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 — 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++

Step 1: Scaffold the Module Project

Create a new directory and initialise it from the minimal module template:

mkdir logos-calc-aggregator-module && cd logos-calc-aggregator-module

1.1 Create the project from the template

nix flake init -t github:logos-co/logos-module-builder/0.2.0

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.

1.2 Remove the template's example sources

The minimal template ships an example Qt plugin (minimal_*). Delete those — this tutorial supplies its own pure-C++ src/ files:

rm -f src/minimal_interface.h src/minimal_plugin.h src/minimal_plugin.cpp

Step 2: Configure the Module

Three small config files declare the module, its dependency on calc_module, and how to build it.

2.1 metadata.json — declare the dependency

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.

{
  "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": []
    }
  }
}
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.

2.2 CMakeLists.txt — list your sources

For a universal module you list only your plain C++ files. The generated dependency glue is compiled automatically.

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
)

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.

2.3 flake.nix — add the dependency input

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).

{
  description = "Aggregator core module - composes calc_module and showcases LogosModuleContext";

  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.0";

    # 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;
    };
}

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 Class

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.

3.1 src/calc_aggregator_impl.h — the class

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.

#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;
};

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".

3.2 src/calc_aggregator_impl.cpp — the implementation

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.

#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;
}

That's the entire module. The three capabilities the SDK base class enables are all here:

  1. Context propertiesmoduleDir(), instanceID(), persistenceDir() just return the base getters; bumpRunCount() + onContextReady() show the persistence dir used for real, durable state.
  2. Typed dependency callscomputeReport() uses the sync wrappers (calc.add(...), …); startAsyncFibonacci() uses the async wrapper (fibonacciAsync(..., callback)).
  3. Typed event subscriptionsubscribeVersion() registers a callback on calc_module's versionReady event via the generated onVersionReady(...) accessor.

Step 4: Build the Module

4.1 Add a .gitignore and init the repo

Nix flakes require a git repository. Exclude build artifacts first:

# Nix build output
result
result-*

# CMake build directory
build/

Initialise the repo and stage the files:

git init && git add -A

4.2 Make sure calc_module is built

The dependency must be built with its shared library present in lib/ (from Part 1). Verify it:

ls ../logos-calc-module/lib/libcalc.so    # Linux
ls ../logos-calc-module/lib/libcalc.dylib  # macOS

If it is missing, build it (as in Part 1, Step 1.5):

cd ../logos-calc-module/lib
gcc -shared -fPIC -o libcalc.so libcalc.c     # Linux
# gcc -shared -fPIC -o libcalc.dylib libcalc.c  # macOS
cd -

4.3 Lock the dependency and build

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:

nix flake update --override-input calc_module path:../logos-calc-module
git add flake.lock

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:

nix build

4.4 Check the output

ls -la result/lib/

You should see your plugin (extension depends on platform):

calc_aggregator_plugin.so     # Linux
calc_aggregator_plugin.dylib  # macOS

Step 5: Inspect the Module

Use lm to confirm the dependency and the public API made it into the binary.

5.1 Build lm

nix build 'github:logos-co/logos-module/0.2.0#lm' --out-link ./lm

5.2 View metadata — note the dependency

./lm/bin/lm metadata result/lib/calc_aggregator_plugin.so    # Linux
./lm/bin/lm metadata result/lib/calc_aggregator_plugin.dylib  # macOS
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.

5.3 List methods

./lm/bin/lm methods result/lib/calc_aggregator_plugin.so    # Linux
./lm/bin/lm methods result/lib/calc_aggregator_plugin.dylib  # macOS

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

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.)

6.1 Build the runtime and package both modules

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:

nix build 'github:logos-co/logos-logoscore-cli/0.2.0' --out-link ./logos
nix build 'github:logos-co/logos-package-manager/0.2.0#cli' --out-link ./pm
mkdir -p modules

6.2 Install calc_aggregator

nix build '.#lgx' --out-link result-aggregator-lgx
./pm/bin/lgpm --modules-dir ./modules install --file result-aggregator-lgx/*.lgx

6.3 Install calc_module (the dependency)

nix build 'path:../logos-calc-module#lgx' --out-link result-calc-lgx
./pm/bin/lgpm --modules-dir ./modules install --file result-calc-lgx/*.lgx

modules/ now holds calc_aggregator/ and calc_module/, each with its plugin, libraries, and manifest.json.

6.4 Create a persistence directory and start the daemon

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:

mkdir -p calc-data

Start logoscore as a background daemon, pointed at the modules directory and the persistence directory:

./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data &
sleep 4

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:

./logos/bin/logoscore load-module calc_module
./logos/bin/logoscore load-module calc_aggregator

6.5 Read the context properties

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:

./logos/bin/logoscore call calc_aggregator moduleDir

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):

./logos/bin/logoscore call calc_aggregator hasInstanceID
./logos/bin/logoscore call calc_aggregator persistenceDir
  • 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

6.6 Compose calc_module synchronously

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:

./logos/bin/logoscore call calc_aggregator computeReport 3 5 10

One call, five composed results:

{"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.

6.7 Compose calc_module asynchronously

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:

./logos/bin/logoscore call calc_aggregator startAsyncFibonacci 20
sleep 1
./logos/bin/logoscore call calc_aggregator asyncResult

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).

6.8 Subscribe to a calc_module event

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:

./logos/bin/logoscore call calc_aggregator subscribeVersion
./logos/bin/logoscore call calc_module libVersionNotify
sleep 1
./logos/bin/logoscore call calc_aggregator lastVersionEvent

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(...).

6.9 Persist state across a restart

bumpRunCount() increments a counter saved in the persistence directory. Call it twice — 1, then 2:

./logos/bin/logoscore call calc_aggregator bumpRunCount
./logos/bin/logoscore call calc_aggregator bumpRunCount

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:

./logos/bin/logoscore stop
sleep 2
./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data &
sleep 4
./logos/bin/logoscore load-module calc_aggregator
./logos/bin/logoscore call calc_aggregator bumpRunCount

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.

./logos/bin/logoscore stop

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

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) or Part 3 (C++ backend), or package it for distribution with nix build '.#lgx-portable' (see Part 1 — Package for Distribution).