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 read `calc_module`'s published LIDL interface contract and generate a typed wrapper for it — without building `calc_module` itself. The dependency name **must match** `calc_module`'s own `metadata.json` `name`. file: path: metadata.json language: json content: | { "name": "calc_aggregator", "version": "1.0.0", "type": "core", "category": "general", "description": "Composes calc_module and showcases LogosModuleContext", "main": "calc_aggregator_plugin", "interface": "universal", "dependencies": ["calc_module"], "nix": { "packages": { "build": [], "runtime": [] }, "external_libraries": [], "cmake": { "find_packages": [], "extra_sources": [], "extra_include_dirs": [], "extra_link_libraries": [] } } } post_text: | | Field | What it does | | -------------- | ------------------------------------------------------------------------------------------------------------- | | `interface` | `"universal"` — you write one plain C++ class; the builder generates the Qt plugin glue | | `dependencies` | `["calc_module"]` — the builder generates `modules().calc_module`, a typed wrapper with sync/async/event APIs | Unlike Part 1, there is no `external_libraries` entry — this module wraps no C library, it depends on another **module**. - title: "`CMakeLists.txt` — list your sources" text: | For a universal module you list only your plain C++ files. The generated dependency glue is compiled automatically. file: path: CMakeLists.txt language: cmake content: | cmake_minimum_required(VERSION 3.14) project(CalcAggregatorPlugin LANGUAGES CXX) # Include the Logos Module CMake helper (provided by logos-module-builder) if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake") include(cmake/LogosModule.cmake) else() message(FATAL_ERROR "LogosModule.cmake not found") endif() logos_module( NAME calc_aggregator SOURCES src/calc_aggregator_impl.h src/calc_aggregator_impl.cpp ) post_text: | `NAME` must match `name` in `metadata.json` (`calc_aggregator`). No `EXTERNAL_LIBS` here — the only dependency is another module, resolved via `metadata.json` + `flake.nix`, not CMake. - title: "`flake.nix` — add the dependency input" text: | Declare `calc_module` as a flake input. The input attribute name **must match** the dependency name in `metadata.json`. The `path:/path/to/your/calc_module` value is a placeholder — you lock it to your real Part 1 checkout in the build step with `--override-input` (Nix won't accept a relative `../` path written directly into `flake.nix`). file: path: flake.nix language: nix content: | { description = "Aggregator core module - composes calc_module and showcases LogosModuleContext"; inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; # The module this one depends on. Placeholder path — locked to your # real checkout in the build step via `--override-input`. calc_module.url = "path:/path/to/your/calc_module"; }; outputs = inputs@{ logos-module-builder, calc_module, ... }: logos-module-builder.lib.mkLogosModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; }; } post_text: | `flakeInputs = inputs` hands every input (including `calc_module`) to the builder, which resolves the `calc_module` dependency declared in `metadata.json` and runs `logos-cpp-generator` to emit the typed wrapper. # ── Step 3: Write the module ──────────────────────────────────────────────── - title: "Write the Module Class" step: true text: | The whole module is one plain C++ class that inherits `LogosModuleContext`. Inheriting that base is what unlocks the context getters (`modulePath()` / `instanceId()` / `instancePersistencePath()`), the `onContextReady()` hook, and `modules()` — typed access to declared dependencies. No Qt anywhere. steps: - title: "`src/calc_aggregator_impl.h` — the class" text: | Every `public` method becomes callable over IPC. The methods fall into four groups: the context getters, the persistence demo, the sync/async composition of `calc_module`, and the event subscription. file: path: src/calc_aggregator_impl.h language: cpp content: | #pragma once #include #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 published LIDL contract, emitting the plugin glue **and** the typed `modules().calc_module` wrapper under `generated_code/` — note `calc_module`'s own plugin is not built here, only its LIDL is read: - run: "nix build" - title: "Check the output" run: "ls -la result/lib/" post_text: | You should see your plugin (extension depends on platform): ``` calc_aggregator_plugin.so # Linux calc_aggregator_plugin.dylib # macOS ``` - check_file: "result/lib/calc_aggregator_plugin.{ext}" # ── Step 5: Inspect ───────────────────────────────────────────────────────── - title: "Inspect the Module" step: true text: | Use `lm` to confirm the dependency and the public API made it into the binary. steps: - title: "Build `lm`" run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm" - title: "View metadata — note the dependency" run: "./lm/bin/lm metadata result/lib/calc_aggregator_plugin.{ext}" code_block: | ./lm/bin/lm metadata result/lib/calc_aggregator_plugin.so # Linux ./lm/bin/lm metadata result/lib/calc_aggregator_plugin.dylib # macOS expect_contains: - "Name: calc_aggregator" - "calc_module" post_text: | ``` Plugin Metadata: ================ Name: calc_aggregator Version: 1.0.0 Description: Composes calc_module and showcases LogosModuleContext Author: Type: core Dependencies: calc_module ``` `Dependencies: calc_module` confirms the link the builder used to generate the typed wrapper. - title: "List methods" run: "./lm/bin/lm methods result/lib/calc_aggregator_plugin.{ext}" code_block: | ./lm/bin/lm methods result/lib/calc_aggregator_plugin.so # Linux ./lm/bin/lm methods result/lib/calc_aggregator_plugin.dylib # macOS expect_contains: - "computeReport" - "startAsyncFibonacci" - "subscribeVersion" - "bumpRunCount" - "persistenceDir" post_text: | Every `public` method on the impl is here, published in the **LIDL contract** vocabulary rather than in C++ or Qt names: `int64_t` shows up as `int`, `std::string` as `tstr`, and `LogosMap` (from `computeReport`) as `{tstr: any}`. `lm` is reporting what the module says about itself, and what a module publishes is its contract — the same words the generated `.lidl` uses, and the same words a Rust or Nim module implementing this contract would answer with. # ── Step 6: Run it with logoscore ─────────────────────────────────────────── - title: "Run it with `logoscore`" step: true text: | Now the payoff: run `calc_aggregator` **and** its `calc_module` dependency under `logoscore` and exercise every capability. We use the `logoscore` **daemon** (`-D`) — it keeps each module's process alive between `call` commands, so an event subscription registered by one call is still active when a later call triggers it, and an async reply lands before the call that reads it. (This is the same daemon flow as [Part 1](tutorial-wrapping-c-library.md#step-6-test-with-logoscore).) steps: - title: "Build the runtime and package both modules" text: | Build `logoscore` and the package manager, then install **both** modules into a `modules/` directory `logoscore` can scan. The aggregator comes from this project; `calc_module` from your Part 1 checkout: run: "nix build 'github:logos-co/logos-logoscore-cli{release}' --out-link ./logos" - run: "nix build 'github:logos-co/logos-package-manager{release}#cli' --out-link ./pm" - run: "mkdir -p modules" - title: "Install calc_aggregator" run: "nix build '.#lgx' --out-link result-aggregator-lgx && ./pm/bin/lgpm --modules-dir ./modules install --file result-aggregator-lgx/*.lgx" code_block: | nix build '.#lgx' --out-link result-aggregator-lgx ./pm/bin/lgpm --modules-dir ./modules install --file result-aggregator-lgx/*.lgx - title: "Install calc_module (the dependency)" run: "nix build 'path:../logos-calc-module#lgx' --out-link result-calc-lgx && ./pm/bin/lgpm --modules-dir ./modules install --file result-calc-lgx/*.lgx" code_block: | nix build 'path:../logos-calc-module#lgx' --out-link result-calc-lgx ./pm/bin/lgpm --modules-dir ./modules install --file result-calc-lgx/*.lgx post_text: | `modules/` now holds `calc_aggregator/` and `calc_module/`, each with its plugin, libraries, and `manifest.json`. - title: "Create a persistence directory and start the daemon" text: | The host only provisions a per-instance persistence path when you pass `--persistence-path`. Create a directory for it — we reuse the **same** directory across restarts so the instance ID (and therefore the persisted state) is stable: run: "mkdir -p calc-data" post_text: "Start `logoscore` as a background daemon, pointed at the modules directory and the persistence directory:" - run: "./logos/bin/logoscore -D -m ./modules --persistence-path ./calc-data &" - run: "sleep 4" post_text: "Load both modules. The daemon keeps each module's process alive between `call` commands, which is what lets an event subscription (or an async reply) survive from one call to the next:" - run: "./logos/bin/logoscore load-module calc_module" - run: "./logos/bin/logoscore load-module calc_aggregator" - title: "Read the context properties" text: | `moduleDir()` / `hasInstanceID()` / `persistenceDir()` return the values the host stamped onto the module. We can't predict the absolute path, but `moduleDir()` must contain the module name: run: "./logos/bin/logoscore call calc_aggregator moduleDir" expect_contains: - "calc_aggregator" post_text: "`hasInstanceID()` returns a bool, so a non-empty instance ID shows as `\"result\":true` — an unambiguous signal the host populated `instanceId()` (a plain string getter would read as empty either way):" - run: "./logos/bin/logoscore call calc_aggregator hasInstanceID" expect_contains: - '"result":true' - run: "./logos/bin/logoscore call calc_aggregator persistenceDir" expect_contains: - "calc-data" - "calc_aggregator" post_text: | - `moduleDir()` → the directory the plugin loaded from (contains `calc_aggregator`) - `hasInstanceID()` → `"result":true` — the host populated `instanceId()` - `persistenceDir()` → a path under your `calc-data/` directory, namespaced by module name and instance ID - title: "Compose calc_module synchronously" text: | `computeReport(a, b, n)` fans out to five typed `calc_module` calls and returns them as one map. With `a=3, b=5, n=10`: run: "./logos/bin/logoscore call calc_aggregator computeReport 3 5 10" expect_contains: - '"sum":8' - '"product":15' - '"factorial":3628800' - '"fibonacci":55' - '"libVersion":"1.0.0"' post_text: | One call, five composed results: ```json {"method":"computeReport","module":"calc_aggregator","result":{"factorial":3628800,"fibonacci":55,"libVersion":"1.0.0","product":15,"sum":8},"status":"ok"} ``` `sum = 3+5`, `product = 3*5`, `factorial = 10!`, `fibonacci = fib(10)`, and `libVersion` read straight from `calc_module` — all through the generated `modules().calc_module` sync wrappers. - title: "Compose calc_module asynchronously" text: | `startAsyncFibonacci(n)` fires `calc_module.fibonacciAsync(n)` and returns `"queued"` immediately. The reply arrives on the daemon's event loop; the next call, `asyncResult()`, reads what the callback stashed. With `n=20`, `fib(20) = 6765`: run: "./logos/bin/logoscore call calc_aggregator startAsyncFibonacci 20" expect_contains: - '"result":"queued"' - run: "sleep 1" - run: "./logos/bin/logoscore call calc_aggregator asyncResult" expect_contains: - '"result":6765' post_text: | `startAsyncFibonacci` returned before the answer existed; by the time `asyncResult()` runs, the async callback has fired and stored `6765`. That's the typed **async** caller — same wrapper, `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)).