mirror of
https://github.com/logos-co/logos-tutorial.git
synced 2026-08-29 20:01:11 +00:00
* doctest: make the bad-bind step outcome-agnostic Asserting status:error relied on the inner call to a missing module blocking long enough to time out the outer RPC. With faster failure detection the plain-int typed wrapper returns the type's default and the outer call reports ok — both surfaces are legitimate, and which one wins is a platform/timing race (consistently the fast path on Linux since the transport extraction, still the slow path on macOS). Assert on the stable part and teach the real contract: bad binds fail at call time; methods that need caller-visible errors should return result. * doctest(qml-ui): gate the Modules click on a wait_for Right after the calculator round-trip the shell is still settling; on a busy CI runner the Modules entry can take longer than the click action's retry budget to materialize (flipped red on both platforms in one run, green the previous hour, identical pins). Same pattern the spec already uses for the post-launch wait. * doctest(qml-ui): open Settings before clicking Modules The Modules entry lives in the Settings left rail; clicking it cold relied on incidental tree state (passed or failed depending on whether the entry happened to be materialized). Mirror the basecamp doctests' proven sequence: click Settings, wait for the view, then click Modules. * tutorial: demonstrate the CallError out-param on bound interface calls Generated typed wrappers now take an optional trailing logos::CallError* — sumVia checks it and returns -1 when the inner call fails (err.code == "object_unavailable" for a missing module), so the bad-bind lesson no longer depends on which way the failure-timing race falls. The doctest assert stays tolerant of the slow-transport outer-timeout surface.
561 lines
35 KiB
YAML
561 lines
35 KiB
YAML
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_<interface>(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 <cstdint>
|
|
#include <string>
|
|
|
|
// Defines the `logos_events` token (expands to `public`) so this
|
|
// header is valid C++ on its own, not only as generator input.
|
|
#include <logos_module_context.h>
|
|
|
|
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 — 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<Event>(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 <cstdint>
|
|
#include <string>
|
|
|
|
#include <logos_module_context.h> // 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.
|
|
// Every generated method also takes an optional trailing
|
|
// logos::CallError* — the explicit way to tell a failed remote
|
|
// call apart from a legitimate result (without it, a failed
|
|
// call returns the type's default and only logs a warning).
|
|
auto calc = modules().bind_calculator(provider);
|
|
logos::CallError err;
|
|
const int64_t sum = calc.add(a, b, &err);
|
|
if (!err.ok()) return -1; // e.g. the bound module isn't loaded
|
|
return sum;
|
|
}
|
|
|
|
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 `<method>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 isn't caught at bind time; it surfaces when you **call** through it — no crash, and the daemon keeps serving. `sumVia` checks the wrapper's `logos::CallError` out-parameter (see its implementation above) and returns `-1` when the inner call fails; on a transport that fails slowly the outer call may instead time out (`RPC_FAILED` / `"status":"error"`). Either way `calc_module` keeps answering (we keep going with `|| true` so the tour continues):
|
|
run: "./logos/bin/logoscore call calc_via_interface sumVia no_such_module 3 5 2>&1 || true"
|
|
expect_contains:
|
|
- 'sumVia'
|
|
post_text: |
|
|
The bound `no_such_module` couldn't be resolved, so the inner `add` call failed — exactly like any other call to an absent module. No crash, no conformance check. Because `sumVia` passes a `logos::CallError*`, it *sees* the failure (`err.code == "object_unavailable"`) and maps it to its own error convention; a call without the out-parameter would get the type's default value plus a warning in the module log. Swapping providers is just changing the string: `sumVia calc_module 3 5` returns `8`; `sumVia no_such_module 3 5` fails. **Any** module that really exposes `add`/`multiply`/`fibonacci`/`libVersion`/`versionReady` satisfies `calculator` and slots 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` → RPC error |
|
|
| 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).
|