persistence: PJ rcpvars + prprocessparams in v2 record format

Closes the v1 caveat: the optional E40-0705 trailers on S16F11 —
recipe variables (RcpVar) and process parameters (ProcessParam),
each carrying a secs2::Item value of arbitrary type — now survive
restart.

Record format bumps to v2:
  v2 header = v1 header
  + [u16 rcpvar_count][repeat: u16 name_len, name, u32 enc_len,
                       secs2::encode(value)]
  + [u16 ppparam_count][...same shape]

v1 records are still accepted by load_record_ (no extras come back).

Two new tests:
  - round-trip mixed F4 / ASCII / U4 / nested-list values through
    rcpvars + prprocessparams
  - hand-crafted v1 record on disk still loads cleanly, just with
    empty extras (proves backwards compat)

Closes the "PJ rcpvars / prprocessparams persistence" caveat from
the post-#1-13 status writeup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:31:58 +02:00
parent f206df763e
commit d9f23d6db8
2 changed files with 162 additions and 16 deletions
+70
View File
@@ -157,6 +157,76 @@ TEST_CASE("ControlJobStore persistence: state + prjob list replay") {
fs::remove_all(dir);
}
TEST_CASE("ProcessJobStore persistence: rcpvars + prprocessparams survive restart") {
auto dir = scratch_dir("pj-extras");
{
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.create("PJ-X", "RCP-1", {"W1"}) ==
ProcessJobStore::CreateResult::Created);
std::vector<RcpVar> rcpvars = {
{"setpoint_c", secsgem::secs2::Item::f4(350.5f)},
{"chamber_name", secsgem::secs2::Item::ascii("CHAMBER-A")},
{"flags", secsgem::secs2::Item::u4(uint32_t{0xDEADBEEFu})},
};
std::vector<ProcessParam> params = {
{"recipe_var", secsgem::secs2::Item::list({
secsgem::secs2::Item::ascii("nested"),
secsgem::secs2::Item::u2(uint16_t{42})})},
};
REQUIRE(s.set_e40_extras("PJ-X", MaterialFlag::Substrate,
ProcessRecipeMethod::RecipeOnly,
rcpvars, params));
}
{
ProcessJobStore s;
s.enable_persistence(dir);
const auto* pj = s.get("PJ-X");
REQUIRE(pj);
REQUIRE(pj->rcpvars.size() == 3);
CHECK(pj->rcpvars[0].name == "setpoint_c");
CHECK(pj->rcpvars[0].value.format() == secsgem::secs2::Format::F4);
CHECK(pj->rcpvars[1].value.as_ascii() == "CHAMBER-A");
CHECK(pj->rcpvars[2].name == "flags");
REQUIRE(pj->prprocessparams.size() == 1);
CHECK(pj->prprocessparams[0].name == "recipe_var");
CHECK(pj->prprocessparams[0].value.is_list());
CHECK(pj->prprocessparams[0].value.as_list().size() == 2);
CHECK(pj->prprocessparams[0].value.as_list()[0].as_ascii() == "nested");
}
fs::remove_all(dir);
}
TEST_CASE("ProcessJobStore persistence: v1 record (no extras) still loads") {
auto dir = scratch_dir("pj-v1");
// Hand-craft a v1 record on disk.
{
std::ofstream f(dir / "0000000001.pj", std::ios::binary);
// [magic=0xC7][version=1][state=Queued=0][alert=1][mf=0][prrecipemethod=0]
const uint8_t header[6] = {0xC7, 0x01, 0x00, 0x01, 0x00, 0x00};
f.write(reinterpret_cast<const char*>(header), 6);
auto write_str = [&](const std::string& s) {
uint16_t n = static_cast<uint16_t>(s.size());
uint8_t lb[2] = {static_cast<uint8_t>(n >> 8), static_cast<uint8_t>(n)};
f.write(reinterpret_cast<const char*>(lb), 2);
f.write(s.data(), n);
};
write_str("PJ-LEGACY"); // prjobid
write_str("RCP-OLD"); // ppid
const uint8_t mc[2] = {0x00, 0x00};
f.write(reinterpret_cast<const char*>(mc), 2);
// No v2 trailer.
}
ProcessJobStore s;
s.enable_persistence(dir);
REQUIRE(s.has("PJ-LEGACY"));
CHECK(s.get("PJ-LEGACY")->ppid == "RCP-OLD");
CHECK(s.get("PJ-LEGACY")->rcpvars.empty());
CHECK(s.get("PJ-LEGACY")->prprocessparams.empty());
fs::remove_all(dir);
}
TEST_CASE("ControlJobStore persistence: remove deletes journal file") {
auto dir = scratch_dir("cj-remove");
ControlJobStore s;