Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/spec/core/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,20 @@ struct IModelHolder {
- `into<Model>()` down-casts to a concrete `Model&`; throws `std::bad_cast` on
mismatch.
- `attachActionLog` sets the durable log sink and the instance's stable identity
(stamped onto every `LogEntry`).
(stamped onto every `LogEntry`), then calls the protected virtual
`onActionLogAttached(log, contextKey)` (base default: no-op) before storing
either. `ModelHolder<Model>` overrides this to forward to
`Model::attachActionLog(log, contextKey)` when `Model` structurally
satisfies `ModelLevelActionLogAttachable` (`morph/core/model.hpp`) — the
same "detect the hook structurally, forward only if present" shape
`onBackendChanged()`/`BackendChangedMixin` use below. This is what lets a
model that keeps its own model-level `IActionLog` reference (to read its
own history back later, e.g. an activity-stream view) receive the same log
instance a registry-constructed, remote/keyed attach populates the holder
with — see [journal.md's "Attaching a log to remote
instances"](../journal/journal.md#attaching-a-log-to-remote-instances). A
model with no `attachActionLog` of its own is unaffected: the hook resolves
to the base's no-op body for it.
- `recordIfAttached` is called automatically by `ActionDispatcher`'s runner and
`Bridge::executeVia` — model code never calls it directly. It fills
`entityKey`, `principal` (from `session::current()`), and `timestampMs` on the
Expand All @@ -363,6 +376,9 @@ struct ModelHolder : IModelHolder, BackendChangedMixin<Model> {
std::type_index type() const noexcept override;
bool isBackendChangeAware() const noexcept override;
void onBackendChanged() override;
protected:
void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>&,
const std::string& contextKey) override;
};
```

Expand Down
16 changes: 16 additions & 0 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,22 @@ void RemoteServer::setLogProvider(LogProvider provider); // thread-safe
returns `nullptr` also attaches no log; otherwise the returned sink is attached
via `holder->attachActionLog(log, contextKey)`, so `contextKey` becomes the
entry `entityKey`.
- **Also reaches a model-level `attachActionLog`, if the model declares one.**
`IModelHolder::attachActionLog` forwards to a protected virtual hook,
`onActionLogAttached`, which `ModelHolder<Model>` overrides to call
`Model::attachActionLog(log, contextKey)` when `Model` structurally satisfies
`ModelLevelActionLogAttachable` (`morph/core/model.hpp`) — the same
"detect the hook structurally, forward only if present" shape
`onBackendChanged()`/`BackendChangedMixin` already use. This closes a real
gap for a model that keeps its own model-level `IActionLog` reference to
read back later (e.g. an activity-stream view over `entries(entityKey)`):
before this hook existed, `holder->attachActionLog(...)` populated only the
holder's own `_actionLog`/`_contextKey` (used by `recordIfAttached`'s
auto-append), never a model instance's own state, since a registry-
constructed model is always default-constructed and never otherwise
touched. A model with no `attachActionLog` of its own is unaffected — the
hook resolves to a no-op for it, exactly as before this existed. First
exercised by `kanban::BoardModel` (rung 4).
- **Installing / removing.** `setLogProvider(nullptr)` removes a previously
installed provider (subsequent registrations get no log). The provider slot is
guarded by its own mutex, and the provider is copied out under that lock before
Expand Down
77 changes: 74 additions & 3 deletions include/morph/core/model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ concept BackendChangedNotifiable = requires(M& model) {
{ model.onBackendChanged() } -> std::same_as<void>;
};

/// @brief Concept satisfied by model types that expose a model-level
/// `attachActionLog(std::shared_ptr<IActionLog>, std::string)`.
///
/// A small number of keyed/shared models (e.g. `kanban::BoardModel`) cannot
/// rely on `IModelHolder::recordIfAttached`'s auto-append alone (that
/// mechanism serves plain fire-after-success journaling; a model that needs
/// to read its own log back, e.g. for an activity-stream view, needs the
/// *same* `IActionLog` instance available on `Model` itself, not only on the
/// type-erased holder wrapping it) and so declares its own `attachActionLog`
/// method with this exact shape, purely by structural convention — there is
/// no shared base class or interface a model opts into. This concept is what
/// lets `ModelHolder<Model>::onActionLogAttached` forward to it generically,
/// the same "detect the hook structurally, forward only if present" shape
/// `BackendChangedNotifiable`/`BackendChangedMixin` already establish for
/// `onBackendChanged()`.
template <typename M>
concept ModelLevelActionLogAttachable = requires(M& model, std::shared_ptr<::morph::journal::IActionLog> log,
std::string key) {
{ model.attachActionLog(log, key) } -> std::same_as<void>;
};

// ── Conditional mixin ─────────────────────────────────────────────────────────

/// @brief Empty base when `M` does not declare `onBackendChanged()`.
Expand Down Expand Up @@ -99,13 +120,23 @@ struct IModelHolder {
/// @brief Attaches a durable action log and this instance's stable identity.
///
/// Set once, typically from the same custom `HandlerBinding::modelFactory`
/// closure already used to inject other dependencies. @p contextKey is stamped
/// onto every `LogEntry` this instance produces (e.g. an account id) so log
/// entries are identifiable without parsing `payload`/`result` JSON.
/// closure already used to inject other dependencies, or from
/// `RemoteServer::LogProvider` for a registry-constructed remote instance
/// (see `docs/spec/journal/journal.md`, "Attaching a log to remote
/// instances"). @p contextKey is stamped onto every `LogEntry` this
/// instance produces (e.g. an account id) so log entries are identifiable
/// without parsing `payload`/`result` JSON.
///
/// Also forwards @p log/@p contextKey to the wrapped model's own
/// `attachActionLog(log, contextKey)`, if it declares one matching
/// `ModelLevelActionLogAttachable` — see `onActionLogAttached`'s doc
/// comment for why this second call exists alongside the holder's own
/// `_actionLog`/`_contextKey` state below.
/// @param log Sink entries are forwarded to. Pass a `SessionLog` to also
/// get undo/checkpoint support.
/// @param contextKey Stable identity of this model instance.
void attachActionLog(std::shared_ptr<::morph::journal::IActionLog> log, std::string contextKey) {
onActionLogAttached(log, contextKey);
_actionLog = std::move(log);
_contextKey = std::move(contextKey);
}
Expand Down Expand Up @@ -156,6 +187,36 @@ struct IModelHolder {
_actionLog->append(std::move(entry));
}

protected:
/// @brief Forwards @p log/@p contextKey to the wrapped model's own
/// `attachActionLog`, if it declares one. No-op default, for the
/// overwhelming majority of models that only ever rely on
/// `recordIfAttached`'s auto-append and have no model-level state
/// of their own to keep a log reference in.
///
/// `ModelHolder<Model>` overrides this exactly like `onBackendChanged()`
/// (see `BackendChangedMixin`): detected structurally via
/// `ModelLevelActionLogAttachable<Model>`, not through a shared
/// interface a model opts into by inheritance. This closes the gap a
/// registry-constructed remote instance would otherwise have — before
/// this hook existed, `RemoteServer::LogProvider`'s `holder->
/// attachActionLog(log, contextKey)` populated only this holder's own
/// `_actionLog`/`_contextKey` (used by `recordIfAttached`'s auto-append),
/// never a `Model`-level `_log`-shaped member a model reads back from
/// itself (e.g. for an activity-stream view): the model instance
/// `into<Model>()` down-casts to is default-constructed by `ModelFactory::
/// create<Model>()` and never otherwise touched. A model with no
/// `attachActionLog` of its own is entirely unaffected — this call
/// resolves to the base's no-op body for it, same as before this hook
/// was added.
/// @param log Sink entries are forwarded to.
/// @param contextKey Stable identity of this model instance.
virtual void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>& log,
const std::string& contextKey) {
(void) log;
(void) contextKey;
}

private:
std::shared_ptr<::morph::journal::IActionLog> _actionLog;
std::string _contextKey;
Expand Down Expand Up @@ -196,6 +257,16 @@ struct ModelHolder : IModelHolder, BackendChangedMixin<Model> {
model.onBackendChanged();
}
}

protected:
/// @brief Forwards to `Model::attachActionLog(log, contextKey)` iff `Model`
/// declared one matching `ModelLevelActionLogAttachable`; no-op otherwise.
void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>& log,
const std::string& contextKey) override {
if constexpr (ModelLevelActionLogAttachable<Model>) {
model.attachActionLog(log, contextKey);
}
}
};

/// @brief Factory that creates default-constructed `ModelHolder<Model>` instances.
Expand Down
4 changes: 3 additions & 1 deletion include/morph/net/socket_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ class SocketBackend : public ::morph::backend::detail::IBackend {
std::vector<std::string> listInstances(const std::string& typeId) override {
std::string replyJson;
try {
replyJson = sendSync(::morph::wire::encode(::morph::wire::makeInstances(typeId)));
auto env = ::morph::wire::makeInstances(typeId);
env.session = currentSession();
replyJson = sendSync(::morph::wire::encode(env));
} catch (const std::exception& exc) {
throw std::runtime_error(std::string{"instances failed: "} + exc.what());
}
Expand Down
4 changes: 3 additions & 1 deletion src/qt/qt_websocket_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,9 @@ bool QtWebSocketBackend::assignPrimaryAsync(::morph::exec::detail::ModelId mid,
}

std::vector<std::string> QtWebSocketBackend::listInstances(const std::string& typeId) {
auto reply = ::morph::wire::decode(sendSync(::morph::wire::encode(::morph::wire::makeInstances(typeId))));
auto env = ::morph::wire::makeInstances(typeId);
env.session = _session;
auto reply = ::morph::wire::decode(sendSync(::morph::wire::encode(env)));
if (reply.kind != "ok") {
throw std::runtime_error("instances failed: " + reply.message);
}
Expand Down
67 changes: 67 additions & 0 deletions tests/test_action_log.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,73 @@ TEST_CASE("IModelHolder: attachActionLog stamps entityKey and timestamp automati
REQUIRE(entries[0].timestampMs > 0);
}

// ── ModelHolder<Model>::onActionLogAttached forwarding ──────────────────────
//
// ALModel above has no model-level attachActionLog of its own, so every test
// above exercises only the ModelLevelActionLogAttachable<Model> == false arm
// of ModelHolder<Model>::onActionLogAttached's `if constexpr`. ALLoggingModel
// declares one matching the concept's exact shape, to exercise the == true
// arm: IModelHolder::attachActionLog forwarding log/contextKey to the
// wrapped model's own attachActionLog, the mechanism a keyed/shared model
// (e.g. kanban::BoardModel) needs to read its own journal back for an
// activity-stream-shaped view, since the type-erased holder's _actionLog is
// otherwise never visible to Model::execute() itself.

struct ALLoggingDeposit {
int amount = 0;
};

struct ALLoggingModel {
std::shared_ptr<IActionLog> log;
std::string contextKey;
int callCount = 0;

void attachActionLog(std::shared_ptr<IActionLog> attachedLog, std::string attachedContextKey) {
log = std::move(attachedLog);
contextKey = std::move(attachedContextKey);
++callCount;
}

int execute(const ALLoggingDeposit& a) { return a.amount; }
};

BRIDGE_REGISTER_MODEL(ALLoggingModel, "AL_LoggingModel")
BRIDGE_REGISTER_ACTION(ALLoggingModel, ALLoggingDeposit, "AL_LoggingDeposit")

TEST_CASE("ModelHolder<Model>::onActionLogAttached forwards to a model-level attachActionLog when the model "
"declares one matching ModelLevelActionLogAttachable",
"[action_log][holder]") {
static_assert(morph::model::detail::ModelLevelActionLogAttachable<ALLoggingModel>);
static_assert(!morph::model::detail::ModelLevelActionLogAttachable<ALModel>);

auto holder = morph::model::detail::ModelFactory::create<ALLoggingModel>();
auto log = std::make_shared<InMemoryActionLog>();
holder->attachActionLog(log, "board-7");

// The holder's own auto-append state (recordIfAttached/hasActionLog)
// still works exactly as before -- this hook adds a second, independent
// forward, it doesn't replace the holder's own bookkeeping.
REQUIRE(holder->hasActionLog());

auto& typed = static_cast<morph::model::detail::ModelHolder<ALLoggingModel>&>(*holder);
REQUIRE(typed.model.callCount == 1);
REQUIRE(typed.model.log == log);
REQUIRE(typed.model.contextKey == "board-7");
}

TEST_CASE("IModelHolder::onActionLogAttached: the no-op default runs when a holder wraps a model with no "
"model-level attachActionLog",
"[action_log][holder]") {
// ALModel doesn't declare attachActionLog, so ModelHolder<ALModel>::
// onActionLogAttached resolves its `if constexpr` false and never calls
// into the model -- attachActionLog must still succeed and populate the
// holder's own state exactly as it did before this hook existed.
auto holder = morph::model::detail::ModelFactory::create<ALModel>();
auto log = std::make_shared<InMemoryActionLog>();
REQUIRE_NOTHROW(holder->attachActionLog(log, "acct-noop"));
REQUIRE(holder->hasActionLog());
}

TEST_CASE("IModelHolder: recordIfAttached captures the active session principal", "[action_log][holder]") {
auto holder = morph::model::detail::ModelFactory::create<ALModel>();
auto log = std::make_shared<InMemoryActionLog>();
Expand Down
Loading