C1: ExceptionStateMachine FSM + ExceptionStore
Per-EXID exception lifecycle for E5 §9. States mirror the wire flow: Posted equipment sent S5F9, awaiting host or autonomous clear Recovering host's S5F13 accepted; equipment running recovery RecoverFailed S5F15 reported a failed result; host may retry Cleared terminal — store removes the row Events: Created synthetic NoState->Posted observer signal Recover host's S5F13 (Posted/RecoverFailed -> Recovering) RecoveryComplete equipment internal (Recovering -> Cleared) RecoveryFailed equipment internal (Recovering -> RecoverFailed) RecoveryAbort host's S5F17 (Recovering -> Posted) Clear equipment internal (Posted/RecoverFailed -> Cleared) ExceptionStore mirrors ProcessJobStore: per-EXID FSMs heap-allocated via unique_ptr, non-movable to keep `this`-captures safe, synthetic Created fires after the row lands so observers can decide whether to emit S5F9 out of band. on_recover validates EXRECVRA against the candidates the post advertised. The store joins EquipmentDataModel alongside process_jobs / control_jobs. S5F9-F18 server-side dispatch lands in C2. Tests (12 cases) cover FSM transitions including retry, abort, and autonomous clear, plus store-level duplicate-rejection, EXRECVRA validation, and Cleared-removes-the-row semantics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/exception_state.hpp"
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// One Exception record. EXID keys the row; EXTYPE / EXMESSAGE are E5
|
||||
// §9 metadata; EXRECVRA lists the recovery-action names the host may
|
||||
// pick from on S5F13. The FSM is heap-allocated so the per-exception
|
||||
// state-change handler can capture a stable pointer.
|
||||
struct Exception {
|
||||
uint32_t exid;
|
||||
std::string extype;
|
||||
std::string exmessage;
|
||||
std::vector<std::string> exrecvra;
|
||||
std::unique_ptr<ExceptionStateMachine> fsm;
|
||||
};
|
||||
|
||||
class ExceptionStore {
|
||||
public:
|
||||
using TransitionTableFactory =
|
||||
std::function<ExceptionTransitionTable()>;
|
||||
using StateChangeHandler =
|
||||
std::function<void(uint32_t exid, ExceptionState from, ExceptionState to,
|
||||
ExceptionEvent trigger)>;
|
||||
|
||||
ExceptionStore()
|
||||
: factory_([] { return ExceptionTransitionTable::default_table(); }) {}
|
||||
|
||||
// Same `this`-capture caveat as ProcessJobStore: keep a stable address.
|
||||
ExceptionStore(const ExceptionStore&) = delete;
|
||||
ExceptionStore& operator=(const ExceptionStore&) = delete;
|
||||
ExceptionStore(ExceptionStore&&) = delete;
|
||||
ExceptionStore& operator=(ExceptionStore&&) = delete;
|
||||
|
||||
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
|
||||
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
enum class PostResult {
|
||||
Posted,
|
||||
Denied_AlreadyExists,
|
||||
};
|
||||
|
||||
// Equipment posts a new exception (S5F9). Returns Denied_AlreadyExists
|
||||
// if an entry for `exid` is already live. Fires a synthetic NoState ->
|
||||
// Posted change so the dispatcher can decide whether to actually emit
|
||||
// the S5F9 (skip if alerts disabled, etc.).
|
||||
PostResult post(uint32_t exid, std::string extype, std::string exmessage,
|
||||
std::vector<std::string> exrecvra) {
|
||||
if (by_id_.count(exid)) return PostResult::Denied_AlreadyExists;
|
||||
auto fsm = std::make_unique<ExceptionStateMachine>(factory_(),
|
||||
ExceptionState::Posted);
|
||||
fsm->set_state_change_handler(
|
||||
[this, exid](ExceptionState from, ExceptionState to, ExceptionEvent trig) {
|
||||
if (on_change_) on_change_(exid, from, to, trig);
|
||||
});
|
||||
by_id_.emplace(exid, Exception{exid, std::move(extype), std::move(exmessage),
|
||||
std::move(exrecvra), std::move(fsm)});
|
||||
if (on_change_) {
|
||||
on_change_(exid, ExceptionState::NoState, ExceptionState::Posted,
|
||||
ExceptionEvent::Created);
|
||||
}
|
||||
return PostResult::Posted;
|
||||
}
|
||||
|
||||
bool has(uint32_t exid) const { return by_id_.count(exid) > 0; }
|
||||
|
||||
const Exception* get(uint32_t exid) const {
|
||||
auto it = by_id_.find(exid);
|
||||
return it == by_id_.end() ? nullptr : &it->second;
|
||||
}
|
||||
Exception* get(uint32_t exid) {
|
||||
auto it = by_id_.find(exid);
|
||||
return it == by_id_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
ExceptionState state(uint32_t exid) const {
|
||||
auto it = by_id_.find(exid);
|
||||
return it == by_id_.end() ? ExceptionState::NoState : it->second.fsm->state();
|
||||
}
|
||||
|
||||
// S5F13 recovery dispatch. Validates EXRECVRA matches one the post
|
||||
// advertised; returns AlarmAck::Error on unknown EXID or unknown action.
|
||||
AlarmAck on_recover(uint32_t exid, const std::string& exrecvra) {
|
||||
auto* ex = get(exid);
|
||||
if (!ex) return AlarmAck::Error;
|
||||
bool known = false;
|
||||
for (const auto& a : ex->exrecvra) if (a == exrecvra) { known = true; break; }
|
||||
if (!known) return AlarmAck::Error;
|
||||
return ex->fsm->on_host_command(ExceptionEvent::Recover);
|
||||
}
|
||||
|
||||
// S5F17 recovery-abort dispatch.
|
||||
AlarmAck on_recover_abort(uint32_t exid) {
|
||||
auto* ex = get(exid);
|
||||
if (!ex) return AlarmAck::Error;
|
||||
return ex->fsm->on_host_command(ExceptionEvent::RecoveryAbort);
|
||||
}
|
||||
|
||||
// Equipment-internal lifecycle events. Cleared transitions remove the
|
||||
// entry — exceptions are not retained past clearance, matching the
|
||||
// E5 §9.3 model where Cleared is the lifecycle's terminus.
|
||||
bool fire_internal(uint32_t exid, ExceptionEvent event) {
|
||||
auto* ex = get(exid);
|
||||
if (!ex) return false;
|
||||
const bool fired = ex->fsm->on_internal(event);
|
||||
if (fired && ex->fsm->state() == ExceptionState::Cleared) {
|
||||
by_id_.erase(exid);
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
std::size_t size() const { return by_id_.size(); }
|
||||
std::vector<uint32_t> ids() const {
|
||||
std::vector<uint32_t> out;
|
||||
out.reserve(by_id_.size());
|
||||
for (const auto& kv : by_id_) out.push_back(kv.first);
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<uint32_t, Exception> by_id_;
|
||||
TransitionTableFactory factory_;
|
||||
StateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
Reference in New Issue
Block a user