refactor(gem): decompose default handlers per GEM capability + YAML role bindings

register_default_handlers was a relocated app main(): one 1086-line function,
all-or-nothing. It is now 15 per-capability registration functions along the
lines GEM itself defines (S1F19): identification, equipment constants, clock,
event reports, remote commands, trace/limits, spooling, alarms, exceptions,
material tracking (E90/E116/E157), carriers (E87), recipes, object services
(E39), jobs (E40/E94), terminal services. A sensor-class tool registers three
functions instead of carrying carrier/job handlers it doesn't have;
register_default_handlers composes all 15. Each function derives exactly the
runtime aliases its handlers use (generated programmatically from the moved
bodies with boundary/substitution guards — zero hand-retyping).

Magic constants are gone: the control-state/clock SVIDs (were hardcoded 1/2)
and the CJ Executing/Completed CEIDs (were 400/401) now come from a "roles:"
block in equipment.yaml via EquipmentDescriptor, with historical defaults
when absent, loader parsing, and validation (CEID roles must name declared
events). The coupling is now visible in ONE file instead of silently split
between YAML and C++ — the exact drift class this repo's spec-as-data
philosophy exists to kill.

Tests: capability subsetting, role-driven SVID refresh via S1F3, roles
loader (shipped/custom/absent). Battery: core 473/3087 incl. the 53-handler
conformance sweep, daemon 125/125, live GEM300 demo (client exit 0), daemon
interop 20/20 vs secsgem-py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:43:41 +02:00
parent cf230d4119
commit 912304966f
9 changed files with 822 additions and 527 deletions
+12
View File
@@ -147,6 +147,18 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
}
// Role bindings: which configured ids the built-in behaviours target.
if (auto roles = root["roles"]) {
if (auto n = roles["control_state_svid"])
desc.control_state_svid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["clock_svid"])
desc.clock_svid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["cj_executing_ceid"])
desc.cj_executing_ceid = static_cast<uint32_t>(n.as<int>());
if (auto n = roles["cj_completed_ceid"])
desc.cj_completed_ceid = static_cast<uint32_t>(n.as<int>());
}
if (auto caps = root["capabilities"]) {
for (const auto& c : caps) {
desc.capabilities.emplace_back(static_cast<uint8_t>(c["code"].as<int>()),
+24
View File
@@ -353,6 +353,30 @@ void validate_equipment_block(YAML::Node& root, Sink& sink) {
}
}
}
// Role bindings: optional; each must be an in-range id, and the CEID
// roles must point at declared collection events (the SVID roles are
// checked loosely — built-ins write them, so a missing SVID is a
// warning-grade misconfig surfaced at runtime by set_value's no-op).
if (auto roles = root["roles"]) {
for (const char* key : {"control_state_svid", "clock_svid"}) {
if (auto n = roles[key])
as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key, 0,
UINT32_MAX);
}
for (const char* key : {"cj_executing_ceid", "cj_completed_ceid"}) {
if (auto n = roles[key]) {
auto v = as_int_in_range<uint32_t>(n, sink, std::string("roles.") + key,
0, UINT32_MAX);
if (v && !ceids.count(*v)) {
sink.error(std::string("roles.") + key,
"CEID " + std::to_string(*v) +
" not declared in `ceids` section",
n);
}
}
}
}
}
YAML::Node try_load(const std::string& path, Sink& sink) {
File diff suppressed because it is too large Load Diff