From c84322d50c6bd9072dda4306fd5cd0a2dba5c85b Mon Sep 17 00:00:00 2001 From: Dario Lipicar Date: Fri, 5 Jun 2026 09:46:55 -0300 Subject: [PATCH] Add "Composing Modules" tutorial: calc_aggregator core module (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add "Composing Modules" tutorial: calc_aggregator core module New executable tutorial (tests/tutorial-composing-modules.test.yaml) that builds calc_aggregator, a core (universal) module depending on calc_module, showcasing every LogosModuleContext capability end-to-end via logoscore: - modulePath / instanceId / instancePersistencePath getters - durable per-instance persistence (a run counter that survives a restart), wired up in onContextReady() - typed sync dependency calls (computeReport composes five calc_module calls into one map) - typed async dependency call (fibonacciAsync with a callback) - typed event subscription (onVersionReady on calc_module's versionReady) Driven entirely from the logoscore daemon (no UI), mirroring Part 1's flow. Also: - README: list the tutorial and the calc_aggregator example module - run.sh: build the module into outputs/ via --workdir (reusing the chain's calc_module, no rebuild) and clean its calc-data/ persistence dir - ci.yml: run the new spec and verify its markdown generation - outputs/: rendered tutorial + cleaned module source The typed event subscriber requires logos-cpp-sdk PR #72 (LIDL parser: accept reserved words as identifiers in name positions) so calc_module's versionReady event sidecar round-trips; the doctest's event step stays red against published deps until that lands and the module builder bumps its SDK pin. Co-Authored-By: Claude Opus 4.8 (1M context) * rerun * Address review: warn about SDK dependency, make CI non-blocking - prerequisites + event step: explicit toolchain note that the typed event subscriber needs logos-cpp-sdk#72 (calc_module's `version` event param collides with a reserved word), with a workaround (pin a fixed logos-module-builder, or skip the event step — everything else works on the released toolchain). - ci.yml: split the Composing Modules spec out of the blocking UI-chain run into its own `continue-on-error: true` step so it doesn't block merges until #72 lands; a comment says to fold it back in once the dep ships. - regenerate outputs/tutorial-composing-modules.md. Co-Authored-By: Claude Opus 4.8 (1M context) * Re-enable Composing Modules as a blocking CI tutorial logos-cpp-sdk#72 (LIDL parser: reserved words usable as identifiers) is merged and logos-module-builder has bumped its SDK pin, so calc_module's versionReady event sidecar now round-trips and the typed onVersionReady subscriber builds on the published toolchain. - ci.yml: fold the spec back into the blocking UI-chain run (and the published two-column report); drop the temporary continue-on-error step. - spec: remove the now-obsolete toolchain prerequisite note and the event-step heads-up. - regenerate outputs/tutorial-composing-modules.md. Verified end-to-end against the published toolchain: 77/77 doctest steps pass, including the build and the full event round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 16 +- README.md | 9 +- .../logos-calc-aggregator-module/.gitignore | 6 + .../CMakeLists.txt | 18 + .../logos-calc-aggregator-module/flake.nix | 18 + .../metadata.json | 24 + .../src/calc_aggregator_impl.cpp | 105 +++ .../src/calc_aggregator_impl.h | 89 +++ outputs/tutorial-composing-modules.md | 695 ++++++++++++++++++ run.sh | 18 +- tests/tutorial-composing-modules.test.yaml | 626 ++++++++++++++++ 11 files changed, 1616 insertions(+), 8 deletions(-) create mode 100644 outputs/logos-calc-aggregator-module/.gitignore create mode 100644 outputs/logos-calc-aggregator-module/CMakeLists.txt create mode 100644 outputs/logos-calc-aggregator-module/flake.nix create mode 100644 outputs/logos-calc-aggregator-module/metadata.json create mode 100644 outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.cpp create mode 100644 outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.h create mode 100644 outputs/tutorial-composing-modules.md create mode 100644 tests/tutorial-composing-modules.test.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f58c50..34405d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,13 +55,18 @@ jobs: # The runner is the shared `doctest` CLI, invoked directly via its flake # (github:logos-co/logos-doctest). The flake bundles Python + PyYAML # (+ rich), so no pip install step is needed. - - name: Test - QML UI App (includes C Library tutorial via requires) + - name: Test - UI chain + Composing Modules (C Library tutorial pulled in via requires) run: | - # --continue-on-fail so the run walks the whole Part 1 -> 2 -> 3 chain - # and the published report is complete. The job still fails (non-zero - # exit) if any step failed; this only changes whether we stop early. + # Two leaves, run back-to-back into one report: + # - tutorial-cpp-ui-app: the Part 1 -> 2 -> 3 chain (requires:) + # - tutorial-composing-modules: the calc_aggregator core module + # (requires Part 1 only) + # --continue-on-fail so the run walks every step and the published + # report is complete. The job still fails (non-zero exit) if any step + # failed; this only changes whether we stop early. nix run github:logos-co/logos-doctest -- run \ tests/tutorial-cpp-ui-app.test.yaml \ + tests/tutorial-composing-modules.test.yaml \ --verbose \ --continue-on-fail \ --report "${{ runner.temp }}/tutorial-report.html" @@ -94,6 +99,9 @@ jobs: nix run github:logos-co/logos-doctest -- generate \ tests/tutorial-qml-ui-app.test.yaml \ -o /tmp/gen-part2.md + nix run github:logos-co/logos-doctest -- generate \ + tests/tutorial-composing-modules.test.yaml \ + -o /tmp/gen-composing.md echo "Generated markdown successfully" publish-report: diff --git a/README.md b/README.md index 4978737..ae32bf8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Step-by-step tutorials that build on each other. Each creates a working module y - **Part 3:** [Building a C++ UI Module (Process-Isolated)](outputs/tutorial-cpp-ui-app.md) — build `calc_ui_cpp`, a `ui_qml` module with a C++ backend that runs in a separate `ui-host` process. Define the remote interface in a `.rep` file; the C++ backend inherits from the generated `SimpleSource`; QML accesses it via a typed replica using `logos.module()` and `QtRemoteObjects.watch()`. Scaffold: `nix flake init -t ...#ui-qml-backend` +- **Composing Modules:** [Composing Modules with the Module Context](outputs/tutorial-composing-modules.md) — build `calc_aggregator`, a `core` module that **depends on `calc_module`** and showcases everything `LogosModuleContext` offers: the `modulePath` / `instanceId` / `instancePersistencePath` properties, per-instance persistence wired up in `onContextReady()`, typed **sync** and **async** dependency callers (`modules().calc_module`), and typed event subscribers. No UI — driven entirely from `logoscore`. Needs only Part 1. + - **logos-dev-boost:** _(⚠️ **EXPERIMENTAL — NOT READY**)_ [Scaffolding Modules with logos-dev-boost](tutorial-dev-boost.md) — use the `logos-dev-boost` CLI to auto-generate modules from C library directories. Wraps libcalc (source-only) and sqlcipher (pre-built `.so`), including integration tests that create encrypted databases. Covers `--type module`, `--type full-app`, and `--lib-dir`. ## Executable Tutorials @@ -99,6 +101,7 @@ Working module source code used by the tutorials: | `logos-calc-module/` | `calc_module` | `core` (wraps libcalc) | Part 1 | | `logos-calc-ui/` | `calc_ui` | `ui_qml` (QML-only) | Part 2 | | `logos-calc-ui-cpp/` | `calc_ui_cpp` | `ui_qml` (C++ backend + QML view) | Part 3 | +| `logos-calc-aggregator-module/` | `calc_aggregator` | `core` (depends on `calc_module`) | Composing Modules | ## Regenerating the outputs @@ -110,9 +113,9 @@ The `outputs/` directory (the rendered `.md` tutorials linked above, plus the bu This: -1. **Runs** the full tutorial chain (Part 1 → 2 → 3) into `./outputs/`, executing every step so the result is verified, not just rendered. Each part lands in its own subdirectory (`outputs/logos-calc-module/`, `outputs/logos-calc-ui/`, `outputs/logos-calc-ui-cpp/`). -2. **Generates** the `.md` tutorial for every `tests/*.test.yaml` spec into `outputs/` (`tutorial-wrapping-c-library.md`, `tutorial-qml-ui-app.md`, `tutorial-cpp-ui-app.md`). -3. **Cleans** each output project so only the source remains — it removes the per-project `.git/` directories (each tutorial `git init`s its project), the nix out-link symlinks (`lm`, `logos`, `pm`, `result*`), build output (`modules/`), and compiled libraries (`*.dylib`, `*.so`). +1. **Runs** the full tutorial chain (Part 1 → 2 → 3) into `./outputs/`, executing every step so the result is verified, not just rendered. Each part lands in its own subdirectory (`outputs/logos-calc-module/`, `outputs/logos-calc-ui/`, `outputs/logos-calc-ui-cpp/`). It then runs the **Composing Modules** tutorial into `outputs/logos-calc-aggregator-module/`, reusing the `calc_module` the chain just built (`--workdir`, so its `requires:` chain is not rebuilt). +2. **Generates** the `.md` tutorial for every `tests/*.test.yaml` spec into `outputs/` (`tutorial-wrapping-c-library.md`, `tutorial-qml-ui-app.md`, `tutorial-cpp-ui-app.md`, `tutorial-composing-modules.md`). +3. **Cleans** each output project so only the source remains — it removes the per-project `.git/` directories (each tutorial `git init`s its project), the nix out-link symlinks (`lm`, `logos`, `pm`, `result*`), build output (`modules/`), compiled libraries (`*.dylib`, `*.so`), and the aggregator tutorial's `calc-data/` persistence scratch dir. ### Pinning to a release tag diff --git a/outputs/logos-calc-aggregator-module/.gitignore b/outputs/logos-calc-aggregator-module/.gitignore new file mode 100644 index 0000000..71cba30 --- /dev/null +++ b/outputs/logos-calc-aggregator-module/.gitignore @@ -0,0 +1,6 @@ +# Nix build output +result +result-* + +# CMake build directory +build/ diff --git a/outputs/logos-calc-aggregator-module/CMakeLists.txt b/outputs/logos-calc-aggregator-module/CMakeLists.txt new file mode 100644 index 0000000..5cfbfb4 --- /dev/null +++ b/outputs/logos-calc-aggregator-module/CMakeLists.txt @@ -0,0 +1,18 @@ +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 +) diff --git a/outputs/logos-calc-aggregator-module/flake.nix b/outputs/logos-calc-aggregator-module/flake.nix new file mode 100644 index 0000000..c0507d9 --- /dev/null +++ b/outputs/logos-calc-aggregator-module/flake.nix @@ -0,0 +1,18 @@ +{ + description = "Aggregator core module - composes calc_module and showcases LogosModuleContext"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + + # 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; + }; +} diff --git a/outputs/logos-calc-aggregator-module/metadata.json b/outputs/logos-calc-aggregator-module/metadata.json new file mode 100644 index 0000000..ab22428 --- /dev/null +++ b/outputs/logos-calc-aggregator-module/metadata.json @@ -0,0 +1,24 @@ +{ + "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": [] + } + } +} diff --git a/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.cpp b/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.cpp new file mode 100644 index 0000000..308910a --- /dev/null +++ b/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.cpp @@ -0,0 +1,105 @@ +#include "calc_aggregator_impl.h" + +#include + +// 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 `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; +} diff --git a/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.h b/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.h new file mode 100644 index 0000000..1df6516 --- /dev/null +++ b/outputs/logos-calc-aggregator-module/src/calc_aggregator_impl.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include // LogosMap (QVariantMap on the wire) +#include // 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; +}; diff --git a/outputs/tutorial-composing-modules.md b/outputs/tutorial-composing-modules.md new file mode 100644 index 0000000..de9c000 --- /dev/null +++ b/outputs/tutorial-composing-modules.md @@ -0,0 +1,695 @@ +# 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](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'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().` 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++ + +--- + +## 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 + +```bash +nix flake init -t github:logos-co/logos-module-builder +``` + +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: + +```bash +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 fetch `calc_module`'s headers and event metadata and generate a typed wrapper for it. The dependency name **must match** `calc_module`'s own `metadata.json` `name`. + +```json +{ + "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 +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`). + +```nix +{ + description = "Aggregator core module - composes calc_module and showcases LogosModuleContext"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + + # 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. + +```cpp +#pragma once + +#include +#include + +#include // LogosMap (QVariantMap on the wire) +#include // 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. + +```cpp +#include "calc_aggregator_impl.h" + +#include + +// 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 `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 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 the Module + +### 4.1 Add a `.gitignore` and init the repo + +Nix flakes require a git repository. Exclude build artifacts first: + +```text +# Nix build output +result +result-* + +# CMake build directory +build/ +``` + +Initialise the repo and stage the files: + +```bash +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](tutorial-wrapping-c-library.md#15-build-the-shared-library)). Verify it: + +```bash +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): + +```bash +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`: + +```bash +nix flake update --override-input calc_module path:../logos-calc-module +``` + +```bash +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 exported interface, emitting the plugin glue **and** the typed `modules().calc_module` wrapper under `generated_code/`: + +```bash +nix build +``` + +### 4.4 Check the output + +```bash +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` + +```bash +nix build 'github:logos-co/logos-module#lm' --out-link ./lm +``` + +### 5.2 View metadata — note the dependency + +```bash +./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 + +```bash +./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 — `int64_t` shows up as `int`, `std::string` as `QString`, and `LogosMap` (from `computeReport`) as `QVariantMap`, because `lm` reports the wire types the generated glue exposes. + +--- + +## 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](tutorial-wrapping-c-library.md#step-6-test-with-logoscore).) + +### 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: + +```bash +nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos +``` + +```bash +nix build 'github:logos-co/logos-package-manager#cli' --out-link ./pm +``` + +```bash +mkdir -p modules +``` + +### 6.2 Install calc_aggregator + +```bash +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) + +```bash +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: + +```bash +mkdir -p calc-data +``` + +Start `logoscore` as a background daemon, pointed at the modules directory and the persistence directory: + +```bash +./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data & +``` + +```bash +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: + +```bash +./logos/bin/logoscore load-module calc_module +``` + +```bash +./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: + +```bash +./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): + +```bash +./logos/bin/logoscore call calc_aggregator hasInstanceID +``` + +```bash +./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`: + +```bash +./logos/bin/logoscore call calc_aggregator computeReport 3 5 10 +``` + +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. + +### 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`: + +```bash +./logos/bin/logoscore call calc_aggregator startAsyncFibonacci 20 +``` + +```bash +sleep 1 +``` + +```bash +./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, `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: + +```bash +./logos/bin/logoscore call calc_aggregator subscribeVersion +``` + +```bash +./logos/bin/logoscore call calc_module libVersionNotify +``` + +```bash +sleep 1 +``` + +```bash +./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`: + +```bash +./logos/bin/logoscore call calc_aggregator bumpRunCount +``` + +```bash +./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`: + +```bash +./logos/bin/logoscore stop +``` + +```bash +sleep 2 +``` + +```bash +./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data & +``` + +```bash +sleep 4 +``` + +```bash +./logos/bin/logoscore load-module calc_aggregator +``` + +```bash +./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. + +```bash +./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)](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)). diff --git a/run.sh b/run.sh index 96f3ac2..2a1926c 100755 --- a/run.sh +++ b/run.sh @@ -67,6 +67,20 @@ echo "==> Running tutorial chain into ${OUTPUT_DIR}/" --output-dir "${OUTPUT_DIR}/" \ ${DOCTEST_ARGS[@]+"${DOCTEST_ARGS[@]}"} +# The Composing Modules tutorial is a separate leaf that needs only Part 1's +# calc_module, which the chain above already built into outputs/logos-calc-module. +# --output-dir runs a single spec/chain, so we slot this one in beside the others +# with --workdir: it runs standalone (its requires: chain is skipped) into a fresh +# empty subdir and reuses ../logos-calc-module — no second calc_module build. +echo "==> Running Composing Modules tutorial into ${OUTPUT_DIR}/logos-calc-aggregator-module/" +rm -rf "${OUTPUT_DIR}/logos-calc-aggregator-module" +mkdir -p "${OUTPUT_DIR}/logos-calc-aggregator-module" +"${DOCTEST[@]}" run tests/tutorial-composing-modules.test.yaml \ + --verbose \ + --workdir "${OUTPUT_DIR}/logos-calc-aggregator-module" \ + --keep-workdir \ + ${DOCTEST_ARGS[@]+"${DOCTEST_ARGS[@]}"} + echo "==> Generating .md tutorials into ${OUTPUT_DIR}/" mkdir -p "${OUTPUT_DIR}" for spec in tests/*.test.yaml; do @@ -82,6 +96,8 @@ if [ ! -d "${OUTPUT_DIR}" ]; then fi echo "==> Cleaning build artifacts from ${OUTPUT_DIR}/" -"${DOCTEST[@]}" clean "${OUTPUT_DIR}" --verbose +# --also calc-data: the Composing Modules tutorial creates a calc-data/ persistence +# directory (logoscore --persistence-path) that the default clean rules don't cover. +"${DOCTEST[@]}" clean "${OUTPUT_DIR}" --also calc-data --verbose echo "==> Done. Cleaned tutorial output is in ${OUTPUT_DIR}/" diff --git a/tests/tutorial-composing-modules.test.yaml b/tests/tutorial-composing-modules.test.yaml new file mode 100644 index 0000000..d45d783 --- /dev/null +++ b/tests/tutorial-composing-modules.test.yaml @@ -0,0 +1,626 @@ +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().` 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 fetch `calc_module`'s headers and event metadata and generate a typed wrapper for it. 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 + #include + + #include // LogosMap (QVariantMap on the wire) + #include // 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 + + // 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 `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 exported interface, emitting the plugin glue **and** the typed `modules().calc_module` wrapper under `generated_code/`: + - 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 — `int64_t` shows up as `int`, `std::string` as `QString`, and `LogosMap` (from `computeReport`) as `QVariantMap`, because `lm` reports the wire types the generated glue exposes. + + # ── 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, `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)).