Skip to content

Architecture

Reqloom is one engine with two front ends. The engine holds all behaviour; the CLI and desktop app are thin adapters over it. That’s why a schema that lints in one behaves identically in the other.

reqloom/
├── engine/ libreqloom-engine — pure C++ engine. No Qt UI.
├── cli/ reqloom — links engine + Qt6::Core only
├── desktop/ Reqloom — the only place Qt UI is allowed
└── ipc/ Phase B scaffold (currently empty)
TargetLinksPurpose
reqloom::engineQt6::Core, yaml-cpp, nlohmann_json, QuickJS, curlSchema, resolution, execution
reqloom-cliengine + Qt6::CoreThe reqloom binary
reqloom-desktopengine + Qt6 Widgets/QuickThe GUI

This is the architectural rule the project actually enforces: the engine must not depend, directly or transitively, on Qt UI or the code editor widget. It’s checked in three places, so it can’t rot:

  1. A CMake link guard. cmake/ReqloomBoundaryGuards.cmake fails configure if the engine, CLI, or engine tests link Qt6::Widgets, Qt6::Gui, Qt6::Quick, Qt6::QuickWidgets, or QScintilla.
  2. A CI grep job. Rejects any #include <QWidget>, <QApplication>, or <Qsci…> under engine/ or cli/.
  3. The public header surface. engine/include/reqloom/engine/*.h uses pImpl and value types; no Qt UI type appears.

Why bother: it keeps the engine testable headlessly, keeps the CLI small enough to run in CI containers, and means the desktop app can be replaced without touching execution logic.

engine/src/
├── domain/ pure logic, no I/O
├── application/ use cases, orchestration
└── infrastructure/ I/O adapters — HTTP, SQLite, keychain, YAML, hooks

Dependencies point inward. Domain knows nothing about YAML or HTTP; it operates on value types and interfaces defined in its own layer.

LayerContainsMay depend on
domainVariableResolver, DependencyResolver, Predicate, ErrorCodesstdlib + Qt6::Core (for QString)
applicationUse cases like RunOperationUseCase, ImportFromOpenApidomain
infrastructureYamlSchemaParser, KeychainSecretStore, QuickJsHookRunner, HTTP clientdomain, application

Each layer is a separate CMake object library, and each is passed through reqloom_forbid_dependencies(...) — so the boundary is a build error, not a convention.

Infrastructure is reached through interfaces declared in the inner layer. The secret store is the clearest example: SecretStore.h is an abstract interface; KeychainSecretStore implements it behind REQLOOM_HAS_QTKEYCHAIN, with a no-op fallback so the engine still links where QtKeychain is absent.

reqloom run refund.approve
│
▼
YamlSchemaParser load root + imports, validate
│ → E_YAML_PARSE / E_SCHEMA_VERSION
▼
DependencyResolver build the graph, Kahn's sort
│ → E_REF_UNDEFINED / E_CYCLE
▼
ExecutionEngine load referenced secrets, then per step:
│ ├── authenticate the actor (cached by TTL)
│ ├── VariableResolver — substitute {{...}}
│ ├── pre_request hook (QuickJS)
│ ├── HTTP request
│ ├── post_response hook
│ ├── poll_until loop, if declared
│ ├── extract into the resource
│ └── evaluate assertions
▼
RunResult steps, statuses, timings, error codes
│
▼
Text / JSON / JUnit renderer

Validation happens once, up front — which is why reqloom lint can catch schema and dependency errors without sending a request.

Every recoverable failure is std::expected<T, ReqloomError>, carrying a stable ErrorCode:

std::expected<RunResult, ReqloomError>
RunOperationUseCase::execute(const OperationId& op, RunContext& ctx);

There’s a single ErrorCode enum for the whole engine — no parallel error types per subsystem. That’s what makes the E_* strings a contract the CLI, desktop, and JSON output all share. Exceptions are reserved for genuinely unrecoverable conditions and never cross an ABI boundary.

RunContext holds everything mutable for one run: actor sessions, resource instances, and the step timeline. It’s move-only and lives exactly as long as the run.

Resource instances are the reason {{order.order_id}} resolves to the newest order and {{order[2].order_id}} to the second — the context keeps a list per resource, and for_each binds an iteration index into it.

The engine doesn’t create threads. Callers decide.

  • Public entry points are safe to call from a single non-GUI thread
  • Concurrent calls on one ExecutionEngine are not supported in the MVP
  • The desktop marshals work with QtConcurrent::run + QFutureWatcher and crosses back to the GUI thread before touching UI state

Two inputs come from outside and are treated as hostile:

SurfaceWhereGuards
Schema filesYamlSchemaParser8 MiB file cap, 64-deep nesting cap
Imported specsImportFromOpenApiPath canonicalisation, containment root

Related guards elsewhere: hook scripts must be relative, inside the project root, and under 1 MiB; uploads cap at 50 MiB; secrets are read individually rather than dumped.

ChoiceVersionWhy
C++23std::expected, std::print, ranges. C++26 is not portable yet
CMake4.0+
Qt6.8 LTSInstalled via aqtinstall, not built from source
Dependenciesvcpkg manifest modeEverything except Qt
TestsGoogleTest852 tests
To understandRead
Accepted schema keysengine/src/infrastructure/schema/YamlSchemaParser.cpp
The {{...}} grammarengine/src/domain/VariableResolver.cpp
Chain buildingengine/src/domain/DependencyResolver.cpp
The error taxonomyengine/include/reqloom/engine/ErrorCodes.h
Public API surfaceengine/include/reqloom/engine/PublicApi.h
CLI output shapescli/src/output/