From 592683156a7a8c29615d53e01bbcf6ec14baa702 Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Fri, 5 Jun 2026 12:13:43 -0300 Subject: [PATCH 1/2] feat: dependency interfaces tutorial (executable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/tutorial-interface-dependencies.test.yaml — an executable logos-doctest tutorial that builds calc_via_interface: a module declaring a `calculator` interface and binding it to calc_module at runtime. Covers typed sync/async/event calls, the no-validation rule, and the cross-repo interface form. Registered in run.sh and CI (run + generate). Documents interface_dependencies + bind_ in the developer guide. Depends on logos-cpp-sdk#74, logos-module-builder#108, logos-plugin-qt#8 — the published flakes must carry these before the tutorial runs green in CI (same release gating as the Composing Modules tutorial). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 + logos-developer-guide.md | 110 ++++ run.sh | 13 + .../tutorial-interface-dependencies.test.yaml | 549 ++++++++++++++++++ 4 files changed, 676 insertions(+) create mode 100644 tests/tutorial-interface-dependencies.test.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34405d0..71c8b22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: nix run github:logos-co/logos-doctest -- run \ tests/tutorial-cpp-ui-app.test.yaml \ tests/tutorial-composing-modules.test.yaml \ + tests/tutorial-interface-dependencies.test.yaml \ --verbose \ --continue-on-fail \ --report "${{ runner.temp }}/tutorial-report.html" @@ -102,6 +103,9 @@ jobs: nix run github:logos-co/logos-doctest -- generate \ tests/tutorial-composing-modules.test.yaml \ -o /tmp/gen-composing.md + nix run github:logos-co/logos-doctest -- generate \ + tests/tutorial-interface-dependencies.test.yaml \ + -o /tmp/gen-interface-deps.md echo "Generated markdown successfully" publish-report: diff --git a/logos-developer-guide.md b/logos-developer-guide.md index d402d9a..d819374 100644 --- a/logos-developer-guide.md +++ b/logos-developer-guide.md @@ -232,6 +232,7 @@ The full set of available fields: | `interface` | No | -- | Set to `"universal"` for the pure-C++ pattern: you write a plain `src/_impl.h`/`.cpp` and the builder runs `logos-cpp-generator --from-header` to synthesize the Qt plugin. Omit for the older hand-written Qt-plugin pattern. | | `view` | Yes (`ui_qml`) | -- | Relative path to the QML entry file (e.g. `Main.qml`). Required for `ui_qml` modules. | | `dependencies` | No | `[]` | Other Logos module names this depends on. Each entry must match the `name` field in that dependency's `metadata.json`. | +| `interface_dependencies` | No | `[]` | Header *interfaces* this module binds at runtime, decoupled from any concrete module. Each entry is `{ name, file, impl_class?, input? }` — see [Dependency interfaces](#dependency-interfaces) and the [tutorial](tutorial-interface-dependencies.md). | | `include` | No | `[]` | Additional files (e.g. shared libraries like `libwaku.so`, `libwaku.dylib`) to bundle alongside the plugin in the output. | | `nix.packages.build` | No | `[]` | Nix packages for build time | | `nix.packages.runtime` | No | `[]` | Nix packages for runtime | @@ -965,6 +966,54 @@ The generated `LogosModules` struct provides a member for each module, with meth > **Prefer async wrappers.** Use `doSomethingAsync(...)` instead of `doSomething(...)` to avoid blocking the caller's thread. Synchronous calls can cause hangs if the target module is slow to respond. +### Dependency Interfaces + +A regular dependency couples a module to **one concrete provider**: you list `other_module` in `dependencies`, and the generated `modules().other_module` wrapper bakes that name into every call. A **dependency interface** instead lets a module declare a *contract* — a list of methods and events — that **any** module exposing a superset of it can satisfy, and bind that contract to a concrete module **chosen at runtime**. + +Declare interfaces in `metadata.json` under `interface_dependencies`, alongside (or instead of) `dependencies`: + +```json +"interface_dependencies": [ + { "name": "calculator", "file": "interfaces/calculator.h", "impl_class": "ICalculator" } +] +``` + +| Field | Required | Meaning | +| ------------ | --------------- | ---------------------------------------------------------------------------------------------------- | +| `name` | Yes | Interface identifier → bound wrapper class (`Calculator`) and the `bind_` factory | +| `file` | Yes | Path to the contract: a pure-C++ `.h` (methods + a `logos_events:` block) or a `.lidl` file | +| `impl_class` | For `.h` files | The class inside the header whose signatures define the contract | +| `input` | No | A flake-input name hosting the interface (same wiring as `dependencies`); omit for a local file | + +The contract is written in the module's own language — for a universal module, a plain header: + +```cpp +// interfaces/calculator.h +class ICalculator { +public: + int64_t add(int64_t a, int64_t b); + std::string libVersion(); +logos_events: + void versionReady(const std::string& version); +}; +``` + +The generator emits a **bound** wrapper whose target module is a constructor argument (not baked in), exposed on `LogosModules` as a `bind_(moduleName)` factory. Bind once, then call as usual: + +```cpp +#include "logos_sdk.h" + +// moduleName is chosen at runtime — config, discovery, user pick, etc. +auto calc = modules().bind_calculator("calc_module"); +int64_t sum = calc.add(3, 5); // synchronous +calc.fibonacciAsync(20, [](int64_t v){ ... }); // async (generated alongside) +calc.onVersionReady([](const std::string& v){ ... }); // typed event subscription +``` + +Binding is **not validated**: a module that does not satisfy the interface surfaces an ordinary remote-call error (a default-valued result), never a crash — so you can swap providers just by changing the bound name. The provider must be loaded at runtime; declaring it in `dependencies` is one way to ensure that, but the interface itself names no module. + +See the [Dependency Interfaces tutorial](tutorial-interface-dependencies.md) for an end-to-end walkthrough, and [Composing Modules](tutorial-composing-modules.md) for the concrete-dependency counterpart. + ### 8.3 LogosResult Many module methods return `LogosResult` for structured success/error handling: @@ -1054,6 +1103,67 @@ Each entry in `dependencies` must match the `name` field in that module's own `m When your module is installed via `lgpm`, its dependencies are automatically resolved and installed first. When loaded via `logos-basecamp`, core module dependencies are loaded before your module. +### 9.3 Exposing Prometheus Metrics + +Infra operators monitor logos.dev nodes with Prometheus. The +[`prometheus_metrics`](https://github.com/logos-co/prometheus-metrics-module) module +serves a `/metrics` HTTP endpoint by querying a configured set of modules — it does +not discover modules or read platform stats, it only calls the modules you list. + +To make your module scrapeable, implement one method by convention: + +``` +collectMetrics() -> LogosMap +``` + +returning prometheus-like fields: + +```json +{ + "metrics": [ + { "name": "storage_blocks_total", "type": "counter", "help": "Total blocks stored", "value": 42 }, + { "name": "storage_peers_connected", "type": "gauge", "help": "Connected peers", "value": 7, "labels": { "protocol": "libp2p" } } + ] +} +``` + +| Field | Meaning | +| -------- | ----------------------------------------------------------------------------------- | +| `name` | Prometheus metric name | +| `type` | `counter`, `gauge`, `histogram`, or `summary` (unknown/missing → `untyped`) | +| `help` | short description | +| `value` | number (bools map to 1/0; numeric strings pass through) | +| `labels` | optional string→string label pairs | + +The metrics server adds a `module=""` label to every series automatically. +Modules that don't implement `collectMetrics` (or that error/time out) are skipped, so +one module never breaks a scrape. + +**Universal (plain C++) module:** + +```cpp +// in _impl.h: LogosMap collectMetrics(); +LogosMap MyModuleImpl::collectMetrics() { + LogosMap metrics = LogosMap::array(); + metrics.push_back({ + {"name", "storage_blocks_total"}, {"type", "counter"}, + {"help", "Total blocks stored"}, {"value", m_blockCount} + }); + return {{"metrics", metrics}}; +} +``` + +**Legacy (Qt) module** — add `Q_INVOKABLE QVariantMap collectMetrics();` returning the +same `{ "metrics": [...] }` shape as a `QVariantMap`/`QVariantList`. + +Then run the metrics server alongside your module and point it at you: + +```bash +logoscore -m -l prometheus_metrics,my_module \ + -c 'prometheus_metrics.start({"port":9090,"modules":["my_module"]})' +curl http://localhost:9090/metrics +``` + --- ## Reference: Repository Map diff --git a/run.sh b/run.sh index 2a1926c..fcc7d7e 100755 --- a/run.sh +++ b/run.sh @@ -81,6 +81,19 @@ mkdir -p "${OUTPUT_DIR}/logos-calc-aggregator-module" --keep-workdir \ ${DOCTEST_ARGS[@]+"${DOCTEST_ARGS[@]}"} +# The Dependency Interfaces tutorial is another leaf that needs only Part 1's +# calc_module (as a runtime provider it binds to by name). Same standalone +# --workdir treatment as Composing Modules: it reuses ../logos-calc-module and +# builds no second calc_module. +echo "==> Running Dependency Interfaces tutorial into ${OUTPUT_DIR}/logos-calc-via-interface-module/" +rm -rf "${OUTPUT_DIR}/logos-calc-via-interface-module" +mkdir -p "${OUTPUT_DIR}/logos-calc-via-interface-module" +"${DOCTEST[@]}" run tests/tutorial-interface-dependencies.test.yaml \ + --verbose \ + --workdir "${OUTPUT_DIR}/logos-calc-via-interface-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 diff --git a/tests/tutorial-interface-dependencies.test.yaml b/tests/tutorial-interface-dependencies.test.yaml new file mode 100644 index 0000000..c4b120c --- /dev/null +++ b/tests/tutorial-interface-dependencies.test.yaml @@ -0,0 +1,549 @@ +name: "Tutorial: Dependency Interfaces — Bind a Module by Contract" +output: tutorial-interface-dependencies.md +project_name: logos-calc-via-interface-module +requires: + - tutorial-wrapping-c-library.test.yaml +release: "" + +intro: | + This tutorial builds `calc_via_interface`, a **core module that depends on an *interface*, not a concrete module**. Instead of naming `calc_module` (from [Part 1](tutorial-wrapping-c-library.md)) as a dependency and getting a fixed `modules().calc_module` wrapper, it declares a small **`calculator` interface** — a list of methods and one event — and **binds that interface to a module name chosen at runtime**. Any module whose API is a *superset* of the interface can satisfy it; `calc_module` is one such provider. You drive the whole thing from `logoscore` on the command line. + +what_you_build: | + A `calc_via_interface` core module that: + + - declares a **`calculator` interface** in its own language (a pure-C++ header with a `logos_events:` block) — `interfaces/calculator.h` + - lists it under `metadata.json`'s `interface_dependencies` — and names **no concrete module** in `dependencies` + - **binds** the interface to a runtime-chosen module with `modules().bind_calculator("calc_module")`, then calls it through the usual type-safe wrappers — **synchronously** (`add`, `multiply`, `libVersion`), **asynchronously** (`fibonacciAsync` + callback), and via a typed **event** subscription (`onVersionReady`) + - proves the **no-validation** contract: binding to a module that does not satisfy the interface fails as an ordinary remote-call error — no crash + + No Qt, no `LogosAPI`, no plugin boilerplate — one plain C++ class, plus a one-file interface contract. + +what_you_learn: + - The difference between a concrete dependency (`dependencies`) and a dependency interface (`interface_dependencies`) + - How to declare an interface in pure C++ (methods + a `logos_events:` block) — or equivalently in `.lidl` + - How the generator turns an interface into a **bound** wrapper whose target module is a constructor argument, exposed as `modules().bind_(moduleName)` + - How to call a bound interface **synchronously** and **asynchronously**, and how to subscribe to its events — all type-safely + - Why binding is decoupled from loading, and what the "superset" / no-validation rule means in practice + - How to share one interface across repos via a flake input (the same wiring `dependencies` use) + +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` as a *runtime* provider; it is never named at build time." + - 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-via-interface-module && cd logos-calc-via-interface-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 **pure-C++ (`interface: universal`) pattern**, so we replace the template's example `src/` files with our own plain `*_impl.h` / `*_impl.cpp`. + + - title: "Remove the template's example sources" + text: | + Delete the example Qt plugin the template ships — 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: Declare the interface ─────────────────────────────────────────── + - title: "Declare the Interface" + step: true + text: | + An **interface** is a method/event contract decoupled from any concrete module. You write it in the *same language as your module* — for a universal module that's a plain C++ header. It looks like an impl class, but you only declare signatures: the generator reads them to build a typed client. + + Put it in an `interfaces/` directory: + steps: + - title: "`interfaces/calculator.h` — the contract" + text: | + The `calculator` interface names four methods and one event. It is deliberately a **subset** of what `calc_module` exposes (which also has `factorial`, `libVersionNotify`, …) — that is the *superset rule*: a provider may expose more than the interface requires. + file: + path: interfaces/calculator.h + language: cpp + content: | + #pragma once + + // A DEPENDENCY INTERFACE: a method/event contract that names no + // module. Any module whose API is a superset of this can satisfy + // it; the consumer binds it to a concrete module name at runtime. + // + // Written in the module's own language (pure C++). The generator + // reads the public methods + the `logos_events:` block and emits a + // BOUND wrapper class `Calculator` whose target module name is a + // runtime constructor argument — not baked in. + // + // Types are std (int64_t / std::string) because the consuming + // module is `interface: "universal"`; the bound wrapper inherits + // that api-style. + + #include + #include + + class ICalculator { + public: + int64_t add(int64_t a, int64_t b); + int64_t multiply(int64_t a, int64_t b); + int64_t fibonacci(int64_t n); + std::string libVersion(); + + logos_events: + // Emitted by the provider; the consumer subscribes through the + // bound wrapper's generated onVersionReady(...) accessor. + void versionReady(const std::string& version); + }; + post_text: | + A few things to notice: + + - The class name (`ICalculator`) and method signatures are all the generator needs. There is no `#include` of any module, no `LogosAPI`, no Qt. + - `logos_events:` (like Qt's `signals:`) marks event declarations. The generator turns each into a typed `on(callback)` subscriber on the bound wrapper. + - You could write the exact same contract as a `.lidl` file instead — `interfaces/calculator.lidl` with `method add(a: int, b: int) -> int` … `event versionReady(version: tstr)`. The `.h` form is shown here because it matches a universal module's own language. + + # ── Step 3: Configure the module ──────────────────────────────────────────── + - title: "Configure the Module" + step: true + text: | + Three config files declare the module, point it at the interface, and tell CMake how to build it. The key contrast with [Composing Modules](tutorial-composing-modules.md): there is **no concrete module** in `dependencies`. + steps: + - title: "`metadata.json` — declare the interface dependency" + text: | + `interface_dependencies` lists the contracts this module binds at runtime. Each entry gives the interface `name` and the `file` that defines it (and, for a `.h` file, the `impl_class` whose signatures define the contract). `dependencies` stays **empty** — we never name `calc_module` at build time. + file: + path: metadata.json + language: json + content: | + { + "name": "calc_via_interface", + "version": "1.0.0", + "type": "core", + "category": "general", + "description": "Binds a calculator interface to a module chosen at runtime", + "main": "calc_via_interface_plugin", + "interface": "universal", + "dependencies": [], + "interface_dependencies": [ + { "name": "calculator", "file": "interfaces/calculator.h", "impl_class": "ICalculator" } + ], + + "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"` — pure C++ impl, the builder generates the Qt plugin glue | + | `dependencies` | `[]` — **no concrete module** is named at build time | + | `interface_dependencies` | `[{ name, file, impl_class }]` — the builder generates the bound wrapper `Calculator` + the `modules().bind_calculator(...)` factory | + + For a `.h` interface the `impl_class` field is required (the class whose signatures define the contract). For a `.lidl` interface, omit it. To pull an interface from **another repo**, add an `"input"` field naming a flake input — covered in the final step. + + - title: "`CMakeLists.txt` — list your sources" + text: | + You list only your plain C++ files. The generated interface wrapper and plugin glue are compiled automatically. + file: + path: CMakeLists.txt + language: cmake + content: | + cmake_minimum_required(VERSION 3.14) + project(CalcViaInterfacePlugin LANGUAGES CXX) + + 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_via_interface + SOURCES + src/calc_via_interface_impl.h + src/calc_via_interface_impl.cpp + ) + post_text: | + `NAME` must match `name` in `metadata.json` (`calc_via_interface`). No module dependency to wire here — the interface file is local to this repo. + + - title: "`flake.nix` — no module inputs needed" + text: | + Because there is no concrete dependency, the only input is the builder itself. (Contrast with Composing Modules, which had to add a `calc_module.url` input.) + file: + path: flake.nix + language: nix + content: | + { + description = "Core module that binds a calculator interface at runtime"; + + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder{release}"; + }; + + outputs = inputs@{ logos-module-builder, ... }: + logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + }; + } + post_text: | + `flakeInputs = inputs` hands the builder everything it needs. It reads `interface_dependencies` from `metadata.json`, resolves the local `interfaces/calculator.h`, and runs `logos-cpp-generator` to emit the bound `Calculator` wrapper into `generated_code/`. + + # ── Step 4: Write the module ──────────────────────────────────────────────── + - title: "Write the Module Class" + step: true + text: | + The module is one plain C++ class inheriting `LogosModuleContext` — that base gives it `modules()`, through which the generated `bind_calculator(name)` factory is reachable. Each method takes the **provider module name** as its first argument, so we can bind to different modules at runtime from `logoscore`. + steps: + - title: "`src/calc_via_interface_impl.h` — the class" + text: | + Every `public` method becomes callable over IPC. They fall into three groups: synchronous binds, an asynchronous bind, and an event subscription. + file: + path: src/calc_via_interface_impl.h + language: cpp + content: | + #pragma once + + #include + #include + + #include // LogosModuleContext base → modules() + + // Binds the `calculator` interface (interfaces/calculator.h) to a + // module name chosen at runtime and calls it through the generated, + // type-safe bound wrapper. It names no concrete module of its own — + // the provider is whatever string you pass in. + class CalcViaInterfaceImpl : public LogosModuleContext { + public: + CalcViaInterfaceImpl() = default; + ~CalcViaInterfaceImpl() = default; + + // ── Synchronous binds ────────────────────────────────────── + // Bind `calculator` to `provider`, then call it. The module + // name appears only at bind time, never on the call. + int64_t sumVia(const std::string& provider, int64_t a, int64_t b); + int64_t productVia(const std::string& provider, int64_t a, int64_t b); + std::string versionVia(const std::string& provider); + + // ── Asynchronous bind ────────────────────────────────────── + // Fire calculator.fibonacci(n) asynchronously against `provider` + // and return immediately ("queued"). Read the reply later with + // lastFib(). + std::string startFibVia(const std::string& provider, int64_t n); + int64_t lastFib() const; + + // ── Event subscription ───────────────────────────────────── + // Subscribe to the interface's `versionReady` event on + // `provider` via the generated onVersionReady(...) accessor. + std::string watchVersion(const std::string& provider); + std::string lastVersion() const; + + private: + int64_t m_lastFib = -1; + std::string m_lastVersion; + }; + post_text: | + The provider name is a plain `std::string` parameter — that is the whole "bind at runtime" idea. The same handle code works for any module that satisfies `calculator`. + + - title: "`src/calc_via_interface_impl.cpp` — the implementation" + text: | + The `.cpp` includes the generated `logos_sdk.h` (which defines `LogosModules` and the `bind_calculator` factory), so the bind/call sites live here rather than in the header the generator parses. + file: + path: src/calc_via_interface_impl.cpp + language: cpp + content: | + #include "calc_via_interface_impl.h" + + // Generated at build time by logos-cpp-generator. Because + // metadata.json lists `interface_dependencies`, LogosModules gains a + // `bind_calculator(moduleName)` factory returning the bound + // `Calculator` wrapper. Included only in the .cpp so the impl header + // the generator parses stays free of generated types. + #include "logos_sdk.h" + + // ── Synchronous binds ─────────────────────────────────────────────── + + int64_t CalcViaInterfaceImpl::sumVia(const std::string& provider, + int64_t a, int64_t b) { + // Bind once, then call normally — no module name on the call. + auto calc = modules().bind_calculator(provider); + return calc.add(a, b); + } + + int64_t CalcViaInterfaceImpl::productVia(const std::string& provider, + int64_t a, int64_t b) { + return modules().bind_calculator(provider).multiply(a, b); + } + + std::string CalcViaInterfaceImpl::versionVia(const std::string& provider) { + return modules().bind_calculator(provider).libVersion(); + } + + // ── Asynchronous bind ──────────────────────────────────────────────── + + std::string CalcViaInterfaceImpl::startFibVia(const std::string& provider, + int64_t n) { + // The generated async overload is `Async(args..., + // callback, timeout)`. It returns immediately; the reply lands in + // the callback on this module's event loop. The bound handle is a + // temporary, but the call is registered on the LogosAPI-owned + // client and the callback captures `this`, so it outlives it. + modules().bind_calculator(provider).fibonacciAsync(n, + [this](int64_t value) { m_lastFib = value; }); + return "queued"; + } + + int64_t CalcViaInterfaceImpl::lastFib() const { + return m_lastFib; + } + + // ── Event subscription ─────────────────────────────────────────────── + + std::string CalcViaInterfaceImpl::watchVersion(const std::string& provider) { + // onVersionReady(...) is generated from the interface's + // `logos_events:` block; the callback's arg type matches the event. + bool ok = modules().bind_calculator(provider).onVersionReady( + [this](const std::string& version) { m_lastVersion = version; }); + return ok ? "ok" : "failed"; + } + + std::string CalcViaInterfaceImpl::lastVersion() const { + return m_lastVersion; + } + post_text: | + Everything flows through `modules().bind_calculator(provider)` — the factory the builder generated from `interface_dependencies`. There is no `modules().calc_module`, because `calc_module` is never a build-time dependency. The bound `Calculator` exposes the same typed sync/async/event API the name-baked wrappers do; the only difference is the target module is chosen when you bind. + + # ── Step 5: Build ─────────────────────────────────────────────────────────── + - title: "Build the Module" + step: true + steps: + - title: "Add a `.gitignore` and init the repo" + text: | + Nix flakes require a git repository, and only tracked files are visible — so `interfaces/calculator.h` must be committed for the generator to find it. 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 (including `interfaces/`):" + run: "git init && git add -A" + + - title: "Build" + text: | + For a universal module with an interface dependency, this is where `logos-cpp-generator` runs over both `src/calc_via_interface_impl.h` (plugin glue) and `interfaces/calculator.h` (the bound `Calculator` wrapper + `bind_calculator` factory), emitting everything 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_via_interface_plugin.so # Linux + calc_via_interface_plugin.dylib # macOS + ``` + - check_file: "result/lib/calc_via_interface_plugin.{ext}" + + # ── Step 6: Inspect ───────────────────────────────────────────────────────── + - title: "Inspect the Module" + step: true + text: | + Use `lm` to confirm the public API made it into the binary — and, tellingly, that there is **no module dependency**. + steps: + - title: "Build `lm`" + run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm" + + - title: "View metadata — note the empty dependency list" + run: "./lm/bin/lm metadata result/lib/calc_via_interface_plugin.{ext}" + code_block: | + ./lm/bin/lm metadata result/lib/calc_via_interface_plugin.so # Linux + ./lm/bin/lm metadata result/lib/calc_via_interface_plugin.dylib # macOS + expect_contains: + - "Name: calc_via_interface" + post_text: | + ``` + Plugin Metadata: + ================ + Name: calc_via_interface + Version: 1.0.0 + Description: Binds a calculator interface to a module chosen at runtime + Type: core + Dependencies: + ``` + + `Dependencies:` is empty — the module is coupled to the `calculator` *contract*, not to any module. + + - title: "List methods" + run: "./lm/bin/lm methods result/lib/calc_via_interface_plugin.{ext}" + code_block: | + ./lm/bin/lm methods result/lib/calc_via_interface_plugin.so # Linux + ./lm/bin/lm methods result/lib/calc_via_interface_plugin.dylib # macOS + expect_contains: + - "sumVia" + - "productVia" + - "versionVia" + - "startFibVia" + - "watchVersion" + post_text: | + Every `public` method is here. `int64_t` shows up as `int` and `std::string` as `QString` — the wire types the generated glue exposes. + + # ── Step 7: Run it with logoscore ─────────────────────────────────────────── + - title: "Run it with `logoscore`" + step: true + text: | + Now the payoff: run `calc_via_interface` and bind its `calculator` interface to the real `calc_module` from Part 1. We use the `logoscore` **daemon** (`-D`) so module processes stay alive between `call` commands — needed for the async reply and the event subscription to survive from one call to the next. (Same daemon flow as [Part 1](tutorial-wrapping-c-library.md#step-6-test-with-logoscore) and [Composing Modules](tutorial-composing-modules.md#run-it-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. `calc_via_interface` comes from this project; `calc_module` from your Part 1 checkout — it is the *provider* we bind to, even though this module never declared it: + 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_via_interface" + run: "nix build '.#lgx' --out-link result-iface-lgx && ./pm/bin/lgpm --modules-dir ./modules install --file result-iface-lgx/*.lgx" + code_block: | + nix build '.#lgx' --out-link result-iface-lgx + ./pm/bin/lgpm --modules-dir ./modules install --file result-iface-lgx/*.lgx + - title: "Install calc_module (the runtime provider)" + text: | + Make sure `calc_module`'s shared library is built (from [Part 1](tutorial-wrapping-c-library.md#15-build-the-shared-library)), then package and install it: + 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: | + # Build libcalc if needed (Part 1, Step 1.5): + cd ../logos-calc-module/lib + gcc -shared -fPIC -o libcalc.so libcalc.c # Linux + # gcc -shared -fPIC -o libcalc.dylib libcalc.c # macOS + cd - + - 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_via_interface/` and `calc_module/`. Neither knows about the other at build time — they meet only at runtime, through the interface. + + - title: "Start the daemon and load both modules" + run: "./logos/bin/logoscore -D -m ./modules &" + - run: "sleep 4" + post_text: "Load the provider and the consumer. The consumer declares no dependency, so we load `calc_module` explicitly:" + - run: "./logos/bin/logoscore load-module calc_module" + - run: "./logos/bin/logoscore load-module calc_via_interface" + + - title: "Bind and call synchronously" + text: | + `sumVia` / `productVia` / `versionVia` each bind `calculator` to the module name you pass, then call through the bound wrapper. Bind to `calc_module`: + run: "./logos/bin/logoscore call calc_via_interface sumVia calc_module 3 5" + expect_contains: + - '"result":8' + - run: "./logos/bin/logoscore call calc_via_interface productVia calc_module 3 5" + expect_contains: + - '"result":15' + - run: "./logos/bin/logoscore call calc_via_interface versionVia calc_module" + expect_contains: + - '"result":"1.0.0"' + post_text: | + `sumVia(calc_module, 3, 5) = 8`, `productVia(calc_module, 3, 5) = 15`, and `versionVia(calc_module) = "1.0.0"` — all through `modules().bind_calculator("calc_module")`, with `calc_module` chosen at call time. + + - title: "Bind and call asynchronously" + text: | + `startFibVia` fires `calculator.fibonacci(n)` asynchronously against the bound module and returns `"queued"`. The reply arrives on the daemon's event loop; `lastFib()` reads it. With `n = 20`, `fib(20) = 6765`: + run: "./logos/bin/logoscore call calc_via_interface startFibVia calc_module 20" + expect_contains: + - '"result":"queued"' + - run: "sleep 1" + - run: "./logos/bin/logoscore call calc_via_interface lastFib" + expect_contains: + - '"result":6765' + post_text: | + The bound wrapper's generated `fibonacciAsync(..., callback)` delivered `6765` to the callback after `startFibVia` had already returned — the typed **async** path, over a runtime-bound interface. + + - title: "Subscribe to a bound interface event" + text: | + `watchVersion` subscribes to the interface's `versionReady` event on the bound module. `calc_module.libVersionNotify()` makes `calc_module` emit it, and `lastVersion()` reads what the typed callback captured: + run: "./logos/bin/logoscore call calc_via_interface watchVersion calc_module" + expect_contains: + - '"result":"ok"' + - run: "./logos/bin/logoscore call calc_module libVersionNotify" + - run: "sleep 1" + - run: "./logos/bin/logoscore call calc_via_interface lastVersion" + expect_contains: + - '"result":"1.0.0"' + post_text: | + `watchVersion` registered the callback via the generated `onVersionReady(...)`; the event fired in between; `lastVersion()` returned `1.0.0` — a typed event subscription on a runtime-bound interface. + + - title: "Bind to a non-satisfying module (the no-validation rule)" + text: | + Binding does **not** validate that the target satisfies the interface — there is no build-time coupling to check against. A bad bind surfaces as an ordinary remote-call failure, not a crash. Bind to a module that doesn't exist and watch the call degrade gracefully to the default value (`0`) instead of `8`: + run: "./logos/bin/logoscore call calc_via_interface sumVia no_such_module 3 5" + expect_contains: + - '"result":0' + post_text: | + No crash, no error dialog — the remote call simply could not be resolved, so the typed wrapper returned the default `int` (`0`). Swapping providers is just changing the string: `sumVia calc_module 3 5` works, `sumVia no_such_module 3 5` quietly fails. **Any** module that really exposes `add`/`multiply`/`fibonacci`/`libVersion`/`versionReady` would satisfy `calculator` and slot in unchanged. + - run: "./logos/bin/logoscore stop" + post_text: | + That completes the tour: a single interface, bound at runtime to a concrete module, driven type-safely for sync calls, async calls, and events — with no build-time dependency on the provider. + + # ── Step 8: Share across repos (prose) ────────────────────────────────────── + - title: "Share an Interface Across Repos" + step: true + text: | + So far `interfaces/calculator.h` lived in this repo. To let *several* modules depend on the **same** contract, move it to its own repo (or a provider repo that publishes the interface it implements) and pull it in as a flake input — exactly how concrete `dependencies` are wired. + + Add an `"input"` field to the `interface_dependencies` entry, naming a flake input, with `file` relative to that input's root: + + ```json + "interface_dependencies": [ + { "name": "calculator", "input": "calc_interfaces", "file": "interfaces/calculator.h", "impl_class": "ICalculator" } + ] + ``` + + and declare the matching input in `flake.nix` (the input attribute name must equal the `input` value): + + ```nix + inputs = { + logos-module-builder.url = "github:logos-co/logos-module-builder"; + calc_interfaces.url = "github:your-org/logos-calc-interfaces"; + }; + ``` + + The builder resolves `calc_interfaces` to a store path, hands the generator the resolved file, and emits the same bound `Calculator` wrapper — only the *source* of the contract moved. Nothing in `src/` changes. (In a workspace, run `ws sync-graph` after editing flake inputs.) + steps: + - text: | + That's the full picture. An interface is a contract you can keep local or share across repos; a module binds it to whatever provider it's given at runtime; and the generated, type-safe wrappers make the bound calls feel exactly like calling a concrete dependency — minus the coupling. + + # ── Recap ──────────────────────────────────────────────────────────────────── + - title: "Recap" + text: | + | Concept | In the code | Seen via `logoscore` | + | -------------------------------- | -------------------------------------------------------- | --------------------------------------------------- | + | Interface declaration | `interfaces/calculator.h` (methods + `logos_events:`) | — | + | Declared, not depended-on | `interface_dependencies` set, `dependencies: []` | `lm metadata` shows empty `Dependencies:` | + | Bind at runtime | `modules().bind_calculator(provider)` | provider is a `call` argument | + | Typed **sync** call | `sumVia` / `productVia` / `versionVia` | `8`, `15`, `1.0.0` | + | Typed **async** call | `startFibVia` → `fibonacciAsync(..., cb)` | `queued`, then `6765` | + | Typed **event** subscription | `watchVersion` → `onVersionReady(cb)` | captured payload `1.0.0` | + | No-validation / superset rule | bind to any module name | `calc_module` → `8`; `no_such_module` → `0` | + | Share across repos | `interface_dependencies[].input` + flake input | — | + + The interface coupled `calc_via_interface` to a *contract*, never to `calc_module`. Any module exposing that contract can be bound in its place — at runtime, by name. + + **Next:** see [Composing Modules](tutorial-composing-modules.md) for the concrete-dependency counterpart (`modules().calc_module`), or give this module a UI with [Part 2 (QML-only)](tutorial-qml-ui-app.md) / [Part 3 (C++ backend)](tutorial-cpp-ui-app.md). From 1176ebf99e545c342c99c8d372a4839b498127dd Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Fri, 5 Jun 2026 12:46:28 -0300 Subject: [PATCH 2/2] address review: self-valid interface header + accurate CI step - Interface calculator.h includes logos_module_context.h so the logos_events token is defined (valid C++ standalone); prose updated accordingly. - CI step name/comment now reflect the third tutorial leaf (tutorial-interface-dependencies), not just the UI chain + Composing Modules. (Copilot review, PR #64.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 6 ++++-- tests/tutorial-interface-dependencies.test.yaml | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c8b22..ecaa5ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,14 @@ 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 - UI chain + Composing Modules (C Library tutorial pulled in via requires) + - name: Test - UI chain + Composing Modules + Dependency Interfaces (C Library tutorial pulled in via requires) run: | - # Two leaves, run back-to-back into one report: + # Three 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) + # - tutorial-interface-dependencies: the calc_via_interface module + # binding a calculator interface at runtime (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. diff --git a/tests/tutorial-interface-dependencies.test.yaml b/tests/tutorial-interface-dependencies.test.yaml index c4b120c..f540c93 100644 --- a/tests/tutorial-interface-dependencies.test.yaml +++ b/tests/tutorial-interface-dependencies.test.yaml @@ -85,6 +85,10 @@ sections: #include #include + // Defines the `logos_events` token (expands to `public`) so this + // header is valid C++ on its own, not only as generator input. + #include + class ICalculator { public: int64_t add(int64_t a, int64_t b); @@ -100,7 +104,7 @@ sections: post_text: | A few things to notice: - - The class name (`ICalculator`) and method signatures are all the generator needs. There is no `#include` of any module, no `LogosAPI`, no Qt. + - The class name (`ICalculator`) and method signatures are all the generator needs — no `LogosAPI`, no Qt, no reference to any concrete module. The one include (`logos_module_context.h`) just defines the `logos_events` token so the header is valid C++ on its own. - `logos_events:` (like Qt's `signals:`) marks event declarations. The generator turns each into a typed `on(callback)` subscriber on the bound wrapper. - You could write the exact same contract as a `.lidl` file instead — `interfaces/calculator.lidl` with `method add(a: int, b: int) -> int` … `event versionReady(version: tstr)`. The `.h` form is shown here because it matches a universal module's own language.