From 2c9aabf5b0b3512d41b8c3f34eb310f8bcb7b283 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:57:00 +0200 Subject: [PATCH 01/35] Add issue 7 implementation generator --- automation/issue7.py | 735 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 automation/issue7.py diff --git a/automation/issue7.py b/automation/issue7.py new file mode 100644 index 0000000..84f8450 --- /dev/null +++ b/automation/issue7.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +ROOT = Path.cwd() + + +def write(path: str, content: str) -> None: + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def add_workspace_dependency(crate_manifest: str, dependency: str) -> None: + path = ROOT / crate_manifest + text = path.read_text(encoding="utf-8") + marker = "[dependencies]\n" + line = f"{dependency}.workspace = true\n" + if line not in text: + text = text.replace(marker, marker + line, 1) + path.write_text(text, encoding="utf-8") + + +add_workspace_dependency("crates/lantern-app/Cargo.toml", "tokio") +for dependency in ["libc", "nix", "thiserror", "tokio", "tokio-serial", "udev"]: + add_workspace_dependency("crates/lantern-transport/Cargo.toml", dependency) + +ports_path = ROOT / "crates/lantern-app/src/ports.rs" +ports = ports_path.read_text(encoding="utf-8") +ports = re.sub( + r"\n(?:///[^\n]*\n)*pub trait PortDiscoveryPort: Send \+ Sync \{.*?\n\}\n", + "\n", + ports, + flags=re.S, +) +ports_path.write_text(ports, encoding="utf-8") + +lib_path = ROOT / "crates/lantern-app/src/lib.rs" +lib = lib_path.read_text(encoding="utf-8") +if "mod serial;" not in lib: + lib = lib.replace("mod settings;", "mod settings;\nmod serial;") +if "pub use serial::*;" not in lib: + lib = lib.replace("pub use settings::*;", "pub use settings::*;\npub use serial::*;") +lib_path.write_text(lib, encoding="utf-8") + +write("crates/lantern-app/src/serial.rs", r'''use std::{collections::BTreeMap, path::PathBuf, time::Duration}; + +use lantern_domain::LinkSettings; +use thiserror::Error; +use tokio::sync::mpsc; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SerialPortOrigin { + Udev, + Manual, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum PortPresence { + Present, + Removed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdapterIdentity { + pub stable_id: Option, + pub canonical_device: PathBuf, + pub vendor_id: Option, + pub product_id: Option, + pub serial_number: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SerialPortDescriptor { + pub identity: AdapterIdentity, + pub device_node: PathBuf, + pub subsystem: Option, + pub driver: Option, + pub manufacturer: Option, + pub product: Option, + pub metadata: BTreeMap, + pub presence: PortPresence, + pub origin: SerialPortOrigin, +} + +impl SerialPortDescriptor { + #[must_use] + pub fn manual(path: PathBuf) -> Self { + Self { + identity: AdapterIdentity { + stable_id: None, + canonical_device: path.clone(), + vendor_id: None, + product_id: None, + serial_number: None, + }, + device_node: path, + subsystem: None, + driver: None, + manufacturer: None, + product: None, + metadata: BTreeMap::new(), + presence: PortPresence::Present, + origin: SerialPortOrigin::Manual, + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PortSnapshot { + pub generation: u64, + pub ports: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum PortEventKind { + Added, + Removed, + Changed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortEvent { + pub kind: PortEventKind, + pub descriptor: SerialPortDescriptor, +} + +pub type PortEventReceiver = mpsc::Receiver; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PortSelection { + StableId(PathBuf), + Manual(PathBuf), +} + +impl PortSelection { + #[must_use] + pub fn path(&self) -> &PathBuf { + match self { + Self::StableId(path) | Self::Manual(path) => path, + } + } + + #[must_use] + pub const fn is_stable(&self) -> bool { + matches!(self, Self::StableId(_)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Rs485DirectionConfig { + pub enabled: bool, + pub rts_on_send: bool, + pub rts_after_send: bool, + pub delay_before_send: Duration, + pub delay_after_send: Duration, +} + +impl Default for Rs485DirectionConfig { + fn default() -> Self { + Self { + enabled: true, + rts_on_send: true, + rts_after_send: false, + delay_before_send: Duration::ZERO, + delay_after_send: Duration::ZERO, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SerialOpenRequest { + pub selection: PortSelection, + pub expected_identity: Option, + pub settings: LinkSettings, + pub rs485_direction: Rs485DirectionConfig, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum PortDiscoveryError { + #[error("udev discovery failed: {0}")] + Udev(String), + #[error("hotplug monitor failed: {0}")] + Monitor(String), + #[error("hotplug receiver is unavailable")] + ReceiverUnavailable, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SerialConnectError { + #[error("serial device is missing: {path}")] + Missing { path: PathBuf }, + #[error("permission denied for serial device {path}")] + PermissionDenied { path: PathBuf }, + #[error("serial device is busy: {path}")] + PortBusy { path: PathBuf }, + #[error("serial path is not a character device: {path}")] + NotCharacterDevice { path: PathBuf }, + #[error("serial link settings are invalid: {0}")] + InvalidSettings(String), + #[error("serial adapter identity changed while opening {path}")] + IdentityChanged { path: PathBuf }, + #[error("Linux RS-485 ioctl is unsupported for {path}")] + UnsupportedRs485Ioctl { path: PathBuf }, + #[error("serial I/O failed for {path}: {message}")] + Io { path: PathBuf, message: String }, +} + +pub trait PortDiscoveryPort: Send + Sync { + fn snapshot(&self) -> Result; + fn subscribe(&self) -> Result; +} +''') + +write("crates/lantern-transport/src/lib.rs", r'''//! Linux serial discovery, opening and Modbus transport adapters. + +mod discovery; +mod rs485_ioctl; +mod serial_open; + +pub use discovery::UdevDiscovery; +pub use serial_open::{OpenedSerialPort, SerialPortOpener}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct TransportAdapter; + +impl TransportAdapter { + #[must_use] + pub const fn adapter_name(&self) -> &'static str { + "serial-modbus" + } +} +''') + +write("crates/lantern-transport/src/discovery.rs", r'''use std::{collections::BTreeMap, ffi::OsStr, fs, path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}}; + +use lantern_app::{ + AdapterIdentity, PortDiscoveryError, PortDiscoveryPort, PortEvent, PortEventKind, + PortEventReceiver, PortPresence, PortSnapshot, SerialPortDescriptor, SerialPortOrigin, +}; +use tokio::sync::mpsc; + +const DEFAULT_EVENT_CAPACITY: usize = 64; +const PROPERTY_KEYS: [&str; 7] = [ + "ID_VENDOR_ID", + "ID_MODEL_ID", + "ID_SERIAL_SHORT", + "ID_VENDOR", + "ID_MODEL", + "ID_BUS", + "DEVPATH", +]; + +#[derive(Debug)] +pub struct UdevDiscovery { + generation: AtomicU64, + event_capacity: usize, + by_id_directory: PathBuf, +} + +impl Default for UdevDiscovery { + fn default() -> Self { + Self::new(DEFAULT_EVENT_CAPACITY) + } +} + +impl UdevDiscovery { + #[must_use] + pub fn new(event_capacity: usize) -> Self { + Self { + generation: AtomicU64::new(0), + event_capacity: event_capacity.max(1), + by_id_directory: PathBuf::from("/dev/serial/by-id"), + } + } + + #[cfg(test)] + fn with_by_id_directory(path: PathBuf) -> Self { + Self { + generation: AtomicU64::new(0), + event_capacity: DEFAULT_EVENT_CAPACITY, + by_id_directory: path, + } + } + + fn enumerate(&self) -> Result, PortDiscoveryError> { + let stable_links = stable_link_map(&self.by_id_directory); + let mut enumerator = udev::Enumerator::new() + .map_err(|error| PortDiscoveryError::Udev(error.to_string()))?; + enumerator + .match_subsystem("tty") + .map_err(|error| PortDiscoveryError::Udev(error.to_string()))?; + let devices = enumerator + .scan_devices() + .map_err(|error| PortDiscoveryError::Udev(error.to_string()))?; + let mut ports = devices + .filter_map(|device| descriptor_from_device(&device, PortPresence::Present, &stable_links)) + .collect::>(); + ports.sort_by(|left, right| { + left.identity + .stable_id + .cmp(&right.identity.stable_id) + .then_with(|| left.device_node.cmp(&right.device_node)) + }); + Ok(ports) + } +} + +impl PortDiscoveryPort for UdevDiscovery { + fn snapshot(&self) -> Result { + let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1; + Ok(PortSnapshot { + generation, + ports: self.enumerate()?, + }) + } + + fn subscribe(&self) -> Result { + let mut builder = udev::MonitorBuilder::new() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + builder + .match_subsystem("tty") + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + let mut socket = builder + .listen() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + let stable_directory = self.by_id_directory.clone(); + let (sender, receiver) = mpsc::channel(self.event_capacity); + std::thread::Builder::new() + .name("vfd-lantern-udev".to_owned()) + .spawn(move || { + for event in socket.iter() { + let kind = match event.event_type() { + udev::EventType::Add => PortEventKind::Added, + udev::EventType::Remove => PortEventKind::Removed, + udev::EventType::Change => PortEventKind::Changed, + _ => continue, + }; + let presence = if kind == PortEventKind::Removed { + PortPresence::Removed + } else { + PortPresence::Present + }; + let stable_links = stable_link_map(&stable_directory); + if let Some(descriptor) = + descriptor_from_device(event.device(), presence, &stable_links) + && sender.blocking_send(PortEvent { kind, descriptor }).is_err() + { + break; + } + } + }) + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + Ok(receiver) + } +} + +fn descriptor_from_device( + device: &udev::Device, + presence: PortPresence, + stable_links: &BTreeMap, +) -> Option { + let device_node = device.devnode()?.to_path_buf(); + if !looks_like_serial_tty(&device_node) { + return None; + } + let canonical_device = fs::canonicalize(&device_node).unwrap_or_else(|_| device_node.clone()); + let stable_id = stable_links.get(&canonical_device).cloned(); + let mut metadata = BTreeMap::new(); + for key in PROPERTY_KEYS { + if let Some(value) = device.property_value(key).and_then(OsStr::to_str) { + metadata.insert(key.to_owned(), value.to_owned()); + } + } + Some(SerialPortDescriptor { + identity: AdapterIdentity { + stable_id, + canonical_device, + vendor_id: property_hex(device, "ID_VENDOR_ID"), + product_id: property_hex(device, "ID_MODEL_ID"), + serial_number: property_text(device, "ID_SERIAL_SHORT"), + }, + device_node, + subsystem: device.subsystem().and_then(OsStr::to_str).map(str::to_owned), + driver: device.driver().and_then(OsStr::to_str).map(str::to_owned), + manufacturer: property_text(device, "ID_VENDOR"), + product: property_text(device, "ID_MODEL"), + metadata, + presence, + origin: SerialPortOrigin::Udev, + }) +} + +fn looks_like_serial_tty(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(OsStr::to_str) else { + return false; + }; + name.starts_with("ttyUSB") + || name.starts_with("ttyACM") + || name.starts_with("ttyAMA") + || name.starts_with("ttyS") + || path.starts_with("/dev/pts") +} + +fn property_text(device: &udev::Device, key: &str) -> Option { + device.property_value(key).and_then(OsStr::to_str).map(str::to_owned) +} + +fn property_hex(device: &udev::Device, key: &str) -> Option { + property_text(device, key).and_then(|value| u16::from_str_radix(&value, 16).ok()) +} + +fn stable_link_map(directory: &Path) -> BTreeMap { + let Ok(entries) = fs::read_dir(directory) else { + return BTreeMap::new(); + }; + let mut links = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let target = fs::canonicalize(&path).ok()?; + Some((target, path)) + }) + .collect::>(); + links.sort_by(|left, right| left.1.cmp(&right.1)); + links.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::symlink}; + + use tempfile::tempdir; + + use super::{stable_link_map, UdevDiscovery}; + + #[test] + fn stable_links_are_deterministic() { + let directory = tempdir().expect("tempdir"); + let target = directory.path().join("ttyUSB0"); + fs::write(&target, b"").expect("target"); + symlink(&target, directory.path().join("usb-z")).expect("z"); + symlink(&target, directory.path().join("usb-a")).expect("a"); + let map = stable_link_map(directory.path()); + assert_eq!(map.values().next(), Some(&directory.path().join("usb-a"))); + let discovery = UdevDiscovery::with_by_id_directory(directory.path().to_path_buf()); + assert_eq!(discovery.event_capacity, 64); + } +} +''') + +write("crates/lantern-transport/src/rs485_ioctl.rs", r'''#![allow(unsafe_code)] + +use std::{io, os::fd::RawFd, time::Duration}; + +use lantern_app::Rs485DirectionConfig; + +const TIOCGRS485: libc::c_ulong = 0x542e; +const TIOCSRS485: libc::c_ulong = 0x542f; +const SER_RS485_ENABLED: u32 = 1 << 0; +const SER_RS485_RTS_ON_SEND: u32 = 1 << 1; +const SER_RS485_RTS_AFTER_SEND: u32 = 1 << 2; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct SerialRs485 { + flags: u32, + delay_rts_before_send: u32, + delay_rts_after_send: u32, + padding: [u32; 5], +} + +pub(crate) fn configure(fd: RawFd, config: Rs485DirectionConfig) -> io::Result<()> { + let mut current = SerialRs485::default(); + // SAFETY: `current` is a writable repr(C) buffer with the Linux serial_rs485 layout, + // and `fd` remains owned by the caller for the duration of this synchronous ioctl. + if unsafe { libc::ioctl(fd, TIOCGRS485, &mut current) } < 0 { + return Err(io::Error::last_os_error()); + } + current.flags &= !(SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND | SER_RS485_RTS_AFTER_SEND); + if config.enabled { + current.flags |= SER_RS485_ENABLED; + } + if config.rts_on_send { + current.flags |= SER_RS485_RTS_ON_SEND; + } + if config.rts_after_send { + current.flags |= SER_RS485_RTS_AFTER_SEND; + } + current.delay_rts_before_send = duration_millis(config.delay_before_send)?; + current.delay_rts_after_send = duration_millis(config.delay_after_send)?; + // SAFETY: `current` is an initialized repr(C) value matching Linux serial_rs485, + // and the kernel copies it synchronously before this function returns. + if unsafe { libc::ioctl(fd, TIOCSRS485, ¤t) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn duration_millis(duration: Duration) -> io::Result { + u32::try_from(duration.as_millis()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "RS-485 delay exceeds u32 ms")) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::duration_millis; + + #[test] + fn rejects_unrepresentable_delay() { + assert!(duration_millis(Duration::from_millis(u64::from(u32::MAX) + 1)).is_err()); + } +} +''') + +write("crates/lantern-transport/src/serial_open.rs", r'''use std::{fs, os::{fd::AsRawFd, unix::fs::FileTypeExt}, path::{Path, PathBuf}}; + +use lantern_app::{AdapterIdentity, PortSelection, SerialConnectError, SerialOpenRequest}; +use lantern_domain::{DataBits, Parity, Rs485Mode, StopBits}; +use tokio_serial::{SerialPort, SerialPortBuilderExt, SerialStream}; + +use crate::rs485_ioctl; + +pub struct OpenedSerialPort { + stream: SerialStream, + canonical_device: PathBuf, +} + +impl OpenedSerialPort { + #[must_use] + pub fn canonical_device(&self) -> &Path { + &self.canonical_device + } + + pub(crate) fn into_stream(self) -> SerialStream { + self.stream + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct SerialPortOpener; + +impl SerialPortOpener { + pub fn open(request: &SerialOpenRequest) -> Result { + let requested_path = request.selection.path(); + let canonical_device = fs::canonicalize(requested_path) + .map_err(|error| map_io_error(requested_path, error))?; + let metadata = fs::metadata(&canonical_device) + .map_err(|error| map_io_error(&canonical_device, error))?; + if !metadata.file_type().is_char_device() { + return Err(SerialConnectError::NotCharacterDevice { + path: canonical_device, + }); + } + verify_expected_identity( + requested_path, + &canonical_device, + request.expected_identity.as_ref(), + )?; + + let settings = request.settings; + let builder = tokio_serial::new(&canonical_device, settings.baud_rate.get()) + .data_bits(match settings.data_bits { + DataBits::Seven => tokio_serial::DataBits::Seven, + DataBits::Eight => tokio_serial::DataBits::Eight, + }) + .parity(match settings.parity { + Parity::None => tokio_serial::Parity::None, + Parity::Even => tokio_serial::Parity::Even, + Parity::Odd => tokio_serial::Parity::Odd, + }) + .stop_bits(match settings.stop_bits { + StopBits::One => tokio_serial::StopBits::One, + StopBits::Two => tokio_serial::StopBits::Two, + }); + let mut stream = builder + .open_native_async() + .map_err(|error| map_serial_error(&canonical_device, error))?; + stream + .set_exclusive(true) + .map_err(|error| map_io_error(&canonical_device, error))?; + + verify_open_descriptor(&stream, &canonical_device)?; + if settings.rs485_mode == Rs485Mode::LinuxIoctl { + rs485_ioctl::configure(stream.as_raw_fd(), request.rs485_direction).map_err(|error| { + if matches!(error.raw_os_error(), Some(libc::ENOTTY | libc::EINVAL)) { + SerialConnectError::UnsupportedRs485Ioctl { + path: canonical_device.clone(), + } + } else { + map_io_error(&canonical_device, error) + } + })?; + } + Ok(OpenedSerialPort { + stream, + canonical_device, + }) + } +} + +fn verify_expected_identity( + requested_path: &Path, + canonical_device: &Path, + expected: Option<&AdapterIdentity>, +) -> Result<(), SerialConnectError> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected.canonical_device != canonical_device { + return Err(SerialConnectError::IdentityChanged { + path: requested_path.to_path_buf(), + }); + } + if let Some(stable_id) = &expected.stable_id + && fs::canonicalize(stable_id).ok().as_deref() != Some(canonical_device) + { + return Err(SerialConnectError::IdentityChanged { + path: requested_path.to_path_buf(), + }); + } + Ok(()) +} + +fn verify_open_descriptor( + stream: &SerialStream, + expected_device: &Path, +) -> Result<(), SerialConnectError> { + let descriptor_path = PathBuf::from(format!("/proc/self/fd/{}", stream.as_raw_fd())); + let actual = fs::canonicalize(&descriptor_path) + .map_err(|error| map_io_error(expected_device, error))?; + if actual != expected_device { + return Err(SerialConnectError::IdentityChanged { + path: expected_device.to_path_buf(), + }); + } + Ok(()) +} + +fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError { + match error.kind() { + tokio_serial::ErrorKind::NoDevice => SerialConnectError::Missing { + path: path.to_path_buf(), + }, + tokio_serial::ErrorKind::InvalidInput => { + SerialConnectError::InvalidSettings(error.to_string()) + } + tokio_serial::ErrorKind::Io(kind) if kind == std::io::ErrorKind::PermissionDenied => { + SerialConnectError::PermissionDenied { + path: path.to_path_buf(), + } + } + _ => SerialConnectError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }, + } +} + +fn map_io_error(path: &Path, error: std::io::Error) -> SerialConnectError { + match (error.kind(), error.raw_os_error()) { + (std::io::ErrorKind::NotFound, _) => SerialConnectError::Missing { + path: path.to_path_buf(), + }, + (std::io::ErrorKind::PermissionDenied, _) => SerialConnectError::PermissionDenied { + path: path.to_path_buf(), + }, + (_, Some(libc::EBUSY)) => SerialConnectError::PortBusy { + path: path.to_path_buf(), + }, + _ => SerialConnectError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use lantern_app::{PortSelection, Rs485DirectionConfig, SerialOpenRequest}; + use lantern_domain::{ + BaudRate, DataBits, LinkSettings, Parity, Rs485Mode, SlaveId, StopBits, + }; + use nix::{pty::openpty, unistd::ttyname}; + + use super::SerialPortOpener; + + fn request(path: std::path::PathBuf) -> SerialOpenRequest { + SerialOpenRequest { + selection: PortSelection::Manual(path), + expected_identity: None, + settings: LinkSettings { + baud_rate: BaudRate::new(9_600).expect("baud"), + parity: Parity::None, + data_bits: DataBits::Eight, + stop_bits: StopBits::One, + response_timeout: Duration::from_millis(100), + slave_id: SlaveId::new(1).expect("slave"), + rs485_mode: Rs485Mode::AdapterManaged, + }, + rs485_direction: Rs485DirectionConfig::default(), + } + } + + #[tokio::test] + async fn opens_a_pty_and_enforces_exclusivity() { + let pty = openpty(None, None).expect("pty"); + let path = ttyname(&pty.slave).expect("tty path"); + let first = SerialPortOpener::open(&request(path.clone())).expect("first open"); + let second = SerialPortOpener::open(&request(path)); + assert!(second.is_err()); + drop(first); + } + + #[test] + fn regular_file_is_rejected_without_opening_serial_transport() { + let file = tempfile::NamedTempFile::new().expect("file"); + let error = SerialPortOpener::open(&request(file.path().to_path_buf())) + .expect_err("regular file must fail"); + assert!(matches!( + error, + lantern_app::SerialConnectError::NotCharacterDevice { .. } + )); + } +} +''') + +main_path = ROOT / "crates/vfd-lantern/src/main.rs" +main = main_path.read_text(encoding="utf-8") +main = main.replace("use lantern_app::{ApplicationState, ArtifactStoragePort, ReadBusPort};", "use lantern_app::{ApplicationState, ArtifactStoragePort};") +main_path.write_text(main, encoding="utf-8") From 7909748db159353e245e3cc47dd49de31262213d Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:57:26 +0200 Subject: [PATCH 02/35] Run tested issue 7 candidate build --- .github/workflows/build-issue7-candidate.yml | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/build-issue7-candidate.yml diff --git a/.github/workflows/build-issue7-candidate.yml b/.github/workflows/build-issue7-candidate.yml new file mode 100644 index 0000000..c9fe7ff --- /dev/null +++ b/.github/workflows/build-issue7-candidate.yml @@ -0,0 +1,53 @@ +name: Build tested issue 7 candidate + +on: + push: + branches: [automation/finish-issues-1-9] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-24.04 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates git libudev-dev pkg-config python3 rustup + - name: Check out automation branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy + rustup default 1.97.1 + - name: Build and validate logical issue 7 commit + shell: bash + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + cp automation/issue7.py /tmp/issue7.py + git fetch origin agent/issues-1-9 + git checkout -B issue7 origin/agent/issues-1-9 + python3 /tmp/issue7.py + cargo generate-lockfile + cargo fmt --all + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check + git config user.name "vfd-lantern-ci" + git config user.email "actions@users.noreply.github.com" + git add -A + git commit -m "Implement serial discovery and Linux RS-485 support (#7)" + test "$(git rev-list --count origin/main..HEAD)" -eq 7 + git push --force origin HEAD:agent/issues-1-9-candidate From b402d5f8b046b6e9412271e9a3b0bf4e8d73cdfe Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:59:13 +0200 Subject: [PATCH 03/35] Correct issue 7 candidate generator --- automation/patch_issue7_v2.py | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 automation/patch_issue7_v2.py diff --git a/automation/patch_issue7_v2.py b/automation/patch_issue7_v2.py new file mode 100644 index 0000000..e7d5211 --- /dev/null +++ b/automation/patch_issue7_v2.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path.cwd() + +manifest = root / "crates/lantern-transport/Cargo.toml" +text = manifest.read_text(encoding="utf-8") +if "[dev-dependencies]" not in text: + text += "\n[dev-dependencies]\ntempfile.workspace = true\n" +elif "tempfile.workspace = true" not in text: + text = text.replace("[dev-dependencies]\n", "[dev-dependencies]\ntempfile.workspace = true\n", 1) +manifest.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/discovery.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "descriptor_from_device(event.device(), presence, &stable_links)", + "descriptor_from_device(&event, presence, &stable_links)", +) +text = text.replace( + "links.sort_by(|left, right| left.1.cmp(&right.1));\n links.into_iter().collect()", + "links.sort_by(|left, right| left.1.cmp(&right.1));\n let mut result = BTreeMap::new();\n for (target, link) in links {\n result.entry(target).or_insert(link);\n }\n result", +) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/serial_open.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "if matches!(error.raw_os_error(), Some(libc::ENOTTY | libc::EINVAL)) {", + "if matches!(error.raw_os_error(), Some(libc::ENOTTY) | Some(libc::EINVAL)) {", +) +text = text.replace( + "let error = SerialPortOpener::open(&request(file.path().to_path_buf()))\n .expect_err(\"regular file must fail\");\n assert!(matches!(\n error,\n lantern_app::SerialConnectError::NotCharacterDevice { .. }\n ));", + "let result = SerialPortOpener::open(&request(file.path().to_path_buf()));\n assert!(matches!(\n result,\n Err(lantern_app::SerialConnectError::NotCharacterDevice { .. })\n ));", +) +path.write_text(text, encoding="utf-8") From 25cc05079d58897e1fd221edc78765cbe8c78845 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:59:35 +0200 Subject: [PATCH 04/35] Run corrected tested issue 7 candidate build --- .../workflows/build-issue7-candidate-v2.yml | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/build-issue7-candidate-v2.yml diff --git a/.github/workflows/build-issue7-candidate-v2.yml b/.github/workflows/build-issue7-candidate-v2.yml new file mode 100644 index 0000000..221181c --- /dev/null +++ b/.github/workflows/build-issue7-candidate-v2.yml @@ -0,0 +1,65 @@ +name: Build corrected issue 7 candidate + +on: + push: + branches: [automation/finish-issues-1-9-v3] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-24.04 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates git libudev-dev pkg-config python3 rustup + - name: Check out automation branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal --component rustfmt --component clippy + rustup default 1.97.1 + - name: Build and validate logical issue 7 commit + id: validate + shell: bash + run: | + set -euo pipefail + exec > >(tee /tmp/issue7-validation.log) 2>&1 + git config --global --add safe.directory "$GITHUB_WORKSPACE" + cp automation/issue7.py /tmp/issue7.py + cp automation/patch_issue7_v2.py /tmp/patch_issue7_v2.py + git fetch origin agent/issues-1-9 + git checkout -B issue7 origin/agent/issues-1-9 + python3 /tmp/issue7.py + python3 /tmp/patch_issue7_v2.py + cargo generate-lockfile + cargo fmt --all + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check + git config user.name "vfd-lantern-ci" + git config user.email "actions@users.noreply.github.com" + git add -A + git commit -m "Implement serial discovery and Linux RS-485 support (#7)" + test "$(git rev-list --count origin/main..HEAD)" -eq 7 + git push --force origin HEAD:agent/issues-1-9-candidate + - name: Upload validation log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issue7-validation-log + path: /tmp/issue7-validation.log + if-no-files-found: warn + retention-days: 1 From 98c42f32fc0f74937d1c28381c5ef33110af3319 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:04:31 +0200 Subject: [PATCH 05/35] Add issue 8 implementation generator --- automation/issue8.py | 1165 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1165 insertions(+) create mode 100644 automation/issue8.py diff --git a/automation/issue8.py b/automation/issue8.py new file mode 100644 index 0000000..a2fcf5b --- /dev/null +++ b/automation/issue8.py @@ -0,0 +1,1165 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +ROOT = Path.cwd() + + +def write(path: str, content: str) -> None: + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def add_dependency(manifest_path: str, dependency: str) -> None: + path = ROOT / manifest_path + text = path.read_text(encoding="utf-8") + line = f"{dependency}.workspace = true\n" + if line not in text: + text = text.replace("[dependencies]\n", "[dependencies]\n" + line, 1) + path.write_text(text, encoding="utf-8") + + +for dependency in ["tokio", "tokio-util"]: + add_dependency("crates/lantern-app/Cargo.toml", dependency) +for dependency in ["tokio-modbus", "tokio-util"]: + add_dependency("crates/lantern-transport/Cargo.toml", dependency) + +ports_path = ROOT / "crates/lantern-app/src/ports.rs" +ports = ports_path.read_text(encoding="utf-8") +for trait_name in ["ReadBusPort", "WriteBusPort"]: + ports = re.sub( + rf"\n(?:///[^\n]*\n)*pub trait {trait_name}: Send \+ Sync \{{.*?\n\}}\n", + "\n", + ports, + flags=re.S, + ) +ports_path.write_text(ports, encoding="utf-8") + +lib_path = ROOT / "crates/lantern-app/src/lib.rs" +lib = lib_path.read_text(encoding="utf-8") +if "mod bus;" not in lib: + lib = lib.replace("mod ports;", "mod bus;\nmod ports;") +if "mod write_coordinator;" not in lib: + lib = lib.replace("mod serial;", "mod serial;\nmod write_coordinator;") +if "pub use bus::*;" not in lib: + lib = lib.replace("pub use ports::*;", "pub use bus::*;\npub use ports::*;") +if "pub use write_coordinator::*;" not in lib: + lib = lib.replace("pub use serial::*;", "pub use serial::*;\npub use write_coordinator::*;") +lib_path.write_text(lib, encoding="utf-8") + +write("crates/lantern-app/src/bus.rs", r'''use std::{future::Future, pin::Pin, time::{Duration, Instant}}; + +use lantern_domain::{ + ModbusFunction, OperationId, RawRegisters, RegisterBlock, RequestId, SessionId, SlaveId, +}; +use thiserror::Error; + +pub type BusFuture<'a, T> = Pin> + Send + 'a>>; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum RequestClass { + SafetyOneShot, + Interactive, + TelemetryCritical, + Telemetry, + Background, +} + +impl RequestClass { + #[must_use] + pub const fn capacity(self) -> usize { + match self { + Self::SafetyOneShot => 16, + Self::Interactive | Self::TelemetryCritical => 64, + Self::Telemetry => 256, + Self::Background => 32, + } + } + + #[must_use] + pub const fn is_periodic_allowed(self) -> bool { + !matches!(self, Self::SafetyOneShot) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BusRequestContext { + pub request_id: RequestId, + pub session_id: SessionId, + pub class: RequestClass, + pub deadline: Instant, + pub operation_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReadBusRequest { + pub context: BusRequestContext, + pub slave: SlaveId, + pub function: ModbusFunction, + pub block: RegisterBlock, + pub periodic: bool, +} + +impl ReadBusRequest { + pub fn validate(&self) -> Result<(), BusError> { + if self.function.is_write() { + return Err(BusError::InvalidRequest("read request uses a write function")); + } + if self.periodic && !self.context.class.is_periodic_allowed() { + return Err(BusError::InvalidRequest( + "periodic request cannot use SafetyOneShot", + )); + } + self.function + .validate_table(self.block.table()) + .and_then(|()| self.function.validate_count(self.block.count())) + .map_err(|_| BusError::InvalidRequest("invalid Modbus read block")) + } +} + +/// A write capability produced only by the application write authority. +/// +/// ```compile_fail +/// use lantern_app::PreparedBusWrite; +/// let _ = PreparedBusWrite { /* private fields */ }; +/// ``` +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedBusWrite { + context: BusRequestContext, + slave: SlaveId, + function: ModbusFunction, + block: RegisterBlock, + values: RawRegisters, +} + +impl PreparedBusWrite { + pub(crate) fn new( + context: BusRequestContext, + slave: SlaveId, + function: ModbusFunction, + block: RegisterBlock, + values: RawRegisters, + ) -> Result { + if !function.is_write() { + return Err(BusError::InvalidRequest("write capability uses a read function")); + } + function + .validate_table(block.table()) + .and_then(|()| function.validate_count(block.count())) + .map_err(|_| BusError::InvalidRequest("invalid Modbus write block"))?; + if usize::from(block.count().get()) != values.as_slice().len() { + return Err(BusError::InvalidRequest("write value width does not match block")); + } + Ok(Self { + context, + slave, + function, + block, + values, + }) + } + + #[must_use] + pub const fn context(&self) -> BusRequestContext { + self.context + } + + #[must_use] + pub const fn slave(&self) -> SlaveId { + self.slave + } + + #[must_use] + pub const fn function(&self) -> ModbusFunction { + self.function + } + + #[must_use] + pub const fn block(&self) -> RegisterBlock { + self.block + } + + #[must_use] + pub fn values(&self) -> &RawRegisters { + &self.values + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum BusError { + #[error("invalid bus request: {0}")] + InvalidRequest(&'static str), + #[error("serial port was removed")] + PortRemoved, + #[error("permission denied")] + PermissionDenied, + #[error("serial port is busy")] + PortBusy, + #[error("I/O error: {0}")] + Io(String), + #[error("request deadline expired before transmission")] + TimeoutBeforeSend, + #[error("response timeout")] + ResponseTimeout, + #[error("invalid frame or transport failure")] + InvalidFrameOrTransport, + #[error("Modbus exception {code}")] + ProtocolException { code: u8 }, + #[error("invalid Modbus response")] + InvalidResponse, + #[error("request was cancelled")] + Cancelled, + #[error("bounded bus queue is full")] + QueueFull, + #[error("write started but its outcome is unknown")] + OutcomeUnknown, + #[error("bus actor is shutting down")] + Shutdown, +} + +impl BusError { + #[must_use] + pub const fn is_transient_read_error(&self) -> bool { + matches!( + self, + Self::Io(_) | Self::ResponseTimeout | Self::InvalidFrameOrTransport + ) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BusStatisticsSnapshot { + pub reads_started: u64, + pub writes_started: u64, + pub read_retries: u64, + pub write_retries: u64, + pub timeout_before_send: u64, + pub queue_full: u64, + pub safety_bursts: u64, + pub t35_delay: Duration, + pub queue_depths: [usize; 5], + pub recent_round_trip_micros: Vec, +} + +pub trait ReadBusPort: Send + Sync { + fn read(&self, request: ReadBusRequest) -> BusFuture<'static, RawRegisters>; +} + +pub trait WriteBusPort: Send + Sync { + fn write(&self, request: PreparedBusWrite) -> BusFuture<'static, ()>; +} + +pub trait BusControlPort: Send + Sync { + fn statistics(&self) -> BusStatisticsSnapshot; + fn shutdown(&self); +} +''') + +write("crates/lantern-app/src/write_coordinator.rs", r'''use lantern_domain::{ModbusFunction, RawRegisters, RegisterBlock, SlaveId}; + +use crate::{BusError, BusRequestContext, PreparedBusWrite}; + +/// Single authority that may mint transport write capabilities. +/// +/// Its production constructor remains sealed until issues #16, #22 and #23 provide +/// the complete safety, durable-audit and profile-trust dependencies. +pub struct WriteCoordinator { + _sealed: (), +} + +impl WriteCoordinator { + pub(crate) fn prepare_transport_write( + &self, + context: BusRequestContext, + slave: SlaveId, + function: ModbusFunction, + block: RegisterBlock, + values: RawRegisters, + ) -> Result { + PreparedBusWrite::new(context, slave, function, block, values) + } + + #[cfg(test)] + pub(crate) const fn test_only() -> Self { + Self { _sealed: () } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use lantern_domain::{ + ModbusFunction, ModbusTable, RawRegisters, RegisterAddress, RegisterBlock, RegisterCount, + RequestId, SessionId, SlaveId, + }; + + use crate::{BusRequestContext, RequestClass}; + + use super::WriteCoordinator; + + #[test] + fn authority_mints_a_width_checked_capability() { + let block = RegisterBlock::new( + ModbusTable::HoldingRegisters, + RegisterAddress::new(10), + RegisterCount::new(1).expect("count"), + ModbusFunction::WriteSingleRegister, + ) + .expect("block"); + let request = WriteCoordinator::test_only() + .prepare_transport_write( + BusRequestContext { + request_id: RequestId::new(1), + session_id: SessionId::new(1), + class: RequestClass::SafetyOneShot, + deadline: Instant::now() + Duration::from_secs(1), + operation_id: None, + }, + SlaveId::new(1).expect("slave"), + ModbusFunction::WriteSingleRegister, + block, + RawRegisters::new(vec![42]).expect("raw"), + ) + .expect("capability"); + assert_eq!(request.values().as_slice(), &[42]); + } +} +''') + +transport_lib = ROOT / "crates/lantern-transport/src/lib.rs" +lib = transport_lib.read_text(encoding="utf-8") +if "mod bus_actor;" not in lib: + lib = lib.replace("mod discovery;", "mod bus_actor;\nmod discovery;\nmod modbus_backend;") +if "pub use bus_actor" not in lib: + lib = lib.replace( + "pub use discovery::UdevDiscovery;", + "pub use bus_actor::{BusActor, BusActorConfig, BusActorHandle};\npub use discovery::UdevDiscovery;\npub use modbus_backend::TokioModbusBackend;", + ) +transport_lib.write_text(lib, encoding="utf-8") + +write("crates/lantern-transport/src/modbus_backend.rs", r'''use std::{future::Future, pin::Pin, time::Duration}; + +use lantern_app::{BusError, PreparedBusWrite, ReadBusRequest}; +use lantern_domain::{ModbusFunction, RawRegisters, SlaveId}; +use tokio::time::timeout; +use tokio_modbus::{ + client::{Context, rtu}, + prelude::{Reader, SlaveContext, Writer}, + Slave, +}; + +use crate::OpenedSerialPort; + +pub type BackendFuture<'a, T> = Pin> + Send + 'a>>; + +pub trait RtuBackend: Send + 'static { + fn read<'a>(&'a mut self, request: &'a ReadBusRequest) -> BackendFuture<'a, RawRegisters>; + fn write<'a>(&'a mut self, request: &'a PreparedBusWrite) -> BackendFuture<'a, ()>; +} + +pub struct TokioModbusBackend { + context: Context, + response_timeout: Duration, +} + +impl TokioModbusBackend { + #[must_use] + pub fn new(port: OpenedSerialPort, initial_slave: SlaveId, response_timeout: Duration) -> Self { + let context = rtu::attach_slave(port.into_stream(), Slave(initial_slave.get())); + Self { + context, + response_timeout, + } + } +} + +trait IntoBusResult { + fn into_bus_result(self) -> Result; +} + +impl IntoBusResult> for Vec { + fn into_bus_result(self) -> Result, BusError> { + Ok(self) + } +} + +impl IntoBusResult<()> for () { + fn into_bus_result(self) -> Result<(), BusError> { + Ok(()) + } +} + +impl IntoBusResult for Result { + fn into_bus_result(self) -> Result { + self.map_err(|code| BusError::ProtocolException { code: code as u8 }) + } +} + +impl RtuBackend for TokioModbusBackend { + fn read<'a>(&'a mut self, request: &'a ReadBusRequest) -> BackendFuture<'a, RawRegisters> { + Box::pin(async move { + self.context.set_slave(Slave(request.slave.get())); + let future = match request.function { + ModbusFunction::ReadHoldingRegisters => self + .context + .read_holding_registers(request.block.start().get(), request.block.count().get()), + ModbusFunction::ReadInputRegisters => self + .context + .read_input_registers(request.block.start().get(), request.block.count().get()), + _ => return Err(BusError::InvalidRequest("backend received a write as read")), + }; + let response = timeout(self.response_timeout, future) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?; + RawRegisters::new(response).map_err(|_| BusError::InvalidResponse) + }) + } + + fn write<'a>(&'a mut self, request: &'a PreparedBusWrite) -> BackendFuture<'a, ()> { + Box::pin(async move { + self.context.set_slave(Slave(request.slave().get())); + let response = match request.function() { + ModbusFunction::WriteSingleRegister => { + let [value] = request.values().as_slice() else { + return Err(BusError::InvalidRequest("FC06 requires one register")); + }; + timeout( + self.response_timeout, + self.context + .write_single_register(request.block().start().get(), *value), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result() + } + ModbusFunction::WriteMultipleRegisters => timeout( + self.response_timeout, + self.context.write_multiple_registers( + request.block().start().get(), + request.values().as_slice(), + ), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result(), + _ => Err(BusError::InvalidRequest("backend received a read as write")), + }; + response + }) + } +} +''') + +write("crates/lantern-transport/src/bus_actor.rs", r'''use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use lantern_app::{ + BusControlPort, BusError, BusFuture, BusStatisticsSnapshot, PreparedBusWrite, ReadBusPort, + ReadBusRequest, RequestClass, WriteBusPort, +}; +use lantern_domain::{DataBits, LinkSettings, Parity, RawRegisters, StopBits}; +use tokio::{sync::{mpsc, oneshot}, task::JoinHandle, time::sleep}; +use tokio_util::sync::CancellationToken; + +use crate::modbus_backend::RtuBackend; + +const SAFETY_OPERATION_LIMIT: usize = 8; +const SAFETY_BURST_LIMIT: usize = 8; +const RECENT_LATENCY_LIMIT: usize = 256; +const NON_SAFETY_SCHEDULE: [RequestClass; 11] = [ + RequestClass::TelemetryCritical, + RequestClass::Interactive, + RequestClass::TelemetryCritical, + RequestClass::Interactive, + RequestClass::TelemetryCritical, + RequestClass::Interactive, + RequestClass::TelemetryCritical, + RequestClass::Interactive, + RequestClass::Telemetry, + RequestClass::Telemetry, + RequestClass::Background, +]; + +#[derive(Clone, Copy, Debug)] +pub struct BusActorConfig { + pub link: LinkSettings, + pub profile_minimum_inter_frame_delay: Duration, +} + +impl BusActorConfig { + #[must_use] + pub fn t35(self) -> Duration { + self.profile_minimum_inter_frame_delay.max(protocol_t35(self.link)) + } +} + +pub struct BusActor; + +impl BusActor { + #[must_use] + pub fn spawn(backend: B, config: BusActorConfig) -> (BusActorHandle, JoinHandle<()>) { + let cancellation = CancellationToken::new(); + let statistics = Arc::new(Mutex::new(BusStatistics::default())); + let (senders, receivers) = channels(); + let handle = BusActorHandle { + senders, + cancellation: cancellation.clone(), + statistics: Arc::clone(&statistics), + }; + let task = tokio::spawn(run_actor( + backend, + config, + receivers, + cancellation, + statistics, + )); + (handle, task) + } +} + +#[derive(Clone)] +pub struct BusActorHandle { + senders: Senders, + cancellation: CancellationToken, + statistics: Arc>, +} + +impl ReadBusPort for BusActorHandle { + fn read(&self, request: ReadBusRequest) -> BusFuture<'static, RawRegisters> { + let sender = self.senders.for_class(request.context.class).clone(); + let stats = Arc::clone(&self.statistics); + Box::pin(async move { + request.validate()?; + let (reply, receiver) = oneshot::channel(); + sender + .try_send(Command::Read { request, reply }) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => { + lock_stats(&stats).queue_full += 1; + BusError::QueueFull + } + mpsc::error::TrySendError::Closed(_) => BusError::Shutdown, + })?; + receiver.await.unwrap_or(Err(BusError::Shutdown)) + }) + } +} + +impl WriteBusPort for BusActorHandle { + fn write(&self, request: PreparedBusWrite) -> BusFuture<'static, ()> { + let sender = self.senders.for_class(request.context().class).clone(); + let stats = Arc::clone(&self.statistics); + Box::pin(async move { + let (reply, receiver) = oneshot::channel(); + sender + .try_send(Command::Write { request, reply }) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => { + lock_stats(&stats).queue_full += 1; + BusError::QueueFull + } + mpsc::error::TrySendError::Closed(_) => BusError::Shutdown, + })?; + receiver.await.unwrap_or(Err(BusError::Shutdown)) + }) + } +} + +impl BusControlPort for BusActorHandle { + fn statistics(&self) -> BusStatisticsSnapshot { + lock_stats(&self.statistics).snapshot() + } + + fn shutdown(&self) { + self.cancellation.cancel(); + } +} + +#[derive(Clone)] +struct Senders { + safety: mpsc::Sender, + interactive: mpsc::Sender, + telemetry_critical: mpsc::Sender, + telemetry: mpsc::Sender, + background: mpsc::Sender, +} + +impl Senders { + fn for_class(&self, class: RequestClass) -> &mpsc::Sender { + match class { + RequestClass::SafetyOneShot => &self.safety, + RequestClass::Interactive => &self.interactive, + RequestClass::TelemetryCritical => &self.telemetry_critical, + RequestClass::Telemetry => &self.telemetry, + RequestClass::Background => &self.background, + } + } +} + +struct Receivers { + safety: mpsc::Receiver, + interactive: mpsc::Receiver, + telemetry_critical: mpsc::Receiver, + telemetry: mpsc::Receiver, + background: mpsc::Receiver, +} + +fn channels() -> (Senders, Receivers) { + let (safety, safety_rx) = mpsc::channel(RequestClass::SafetyOneShot.capacity()); + let (interactive, interactive_rx) = mpsc::channel(RequestClass::Interactive.capacity()); + let (telemetry_critical, telemetry_critical_rx) = + mpsc::channel(RequestClass::TelemetryCritical.capacity()); + let (telemetry, telemetry_rx) = mpsc::channel(RequestClass::Telemetry.capacity()); + let (background, background_rx) = mpsc::channel(RequestClass::Background.capacity()); + ( + Senders { + safety, + interactive, + telemetry_critical, + telemetry, + background, + }, + Receivers { + safety: safety_rx, + interactive: interactive_rx, + telemetry_critical: telemetry_critical_rx, + telemetry: telemetry_rx, + background: background_rx, + }, + ) +} + +enum Command { + Read { + request: ReadBusRequest, + reply: oneshot::Sender>, + }, + Write { + request: PreparedBusWrite, + reply: oneshot::Sender>, + }, +} + +impl Command { + fn class(&self) -> RequestClass { + match self { + Self::Read { request, .. } => request.context.class, + Self::Write { request, .. } => request.context().class, + } + } + + fn deadline(&self) -> Instant { + match self { + Self::Read { request, .. } => request.context.deadline, + Self::Write { request, .. } => request.context().deadline, + } + } + + fn operation_id(&self) -> Option { + match self { + Self::Read { request, .. } => request.context.operation_id, + Self::Write { request, .. } => request.context().operation_id, + } + } + + fn finish(self, error: BusError) { + match self { + Self::Read { reply, .. } => { + let _ = reply.send(Err(error)); + } + Self::Write { reply, .. } => { + let _ = reply.send(Err(error)); + } + } + } +} + +#[derive(Default)] +struct PendingQueues { + safety: Vec, + interactive: Vec, + telemetry_critical: Vec, + telemetry: Vec, + background: Vec, +} + +impl PendingQueues { + fn push(&mut self, command: Command) { + if command.class() == RequestClass::SafetyOneShot { + let matching = self + .safety + .iter() + .filter(|queued| queued.operation_id() == command.operation_id()) + .count(); + if matching >= SAFETY_OPERATION_LIMIT { + command.finish(BusError::QueueFull); + return; + } + } + self.queue_mut(command.class()).push(command); + } + + fn is_empty(&self) -> bool { + self.safety.is_empty() + && self.interactive.is_empty() + && self.telemetry_critical.is_empty() + && self.telemetry.is_empty() + && self.background.is_empty() + } + + fn queue_mut(&mut self, class: RequestClass) -> &mut Vec { + match class { + RequestClass::SafetyOneShot => &mut self.safety, + RequestClass::Interactive => &mut self.interactive, + RequestClass::TelemetryCritical => &mut self.telemetry_critical, + RequestClass::Telemetry => &mut self.telemetry, + RequestClass::Background => &mut self.background, + } + } + + fn earliest(&mut self, class: RequestClass) -> Option { + let queue = self.queue_mut(class); + let index = queue + .iter() + .enumerate() + .min_by_key(|(index, command)| (command.deadline(), *index)) + .map(|(index, _)| index)?; + Some(queue.remove(index)) + } + + fn depth(&self, class: RequestClass) -> usize { + match class { + RequestClass::SafetyOneShot => self.safety.len(), + RequestClass::Interactive => self.interactive.len(), + RequestClass::TelemetryCritical => self.telemetry_critical.len(), + RequestClass::Telemetry => self.telemetry.len(), + RequestClass::Background => self.background.len(), + } + } +} + +async fn run_actor( + mut backend: B, + config: BusActorConfig, + mut receivers: Receivers, + cancellation: CancellationToken, + statistics: Arc>, +) { + let mut pending = PendingQueues::default(); + let mut safety_burst = 0_usize; + let mut wrr_index = 0_usize; + let mut last_transmission_end = None; + + loop { + drain_receivers(&mut receivers, &mut pending); + if cancellation.is_cancelled() { + reject_all(&mut pending, BusError::Shutdown); + drain_and_reject(&mut receivers, BusError::Shutdown); + break; + } + if pending.is_empty() { + tokio::select! { + _ = cancellation.cancelled() => continue, + value = receivers.safety.recv() => push_option(value, &mut pending), + value = receivers.interactive.recv() => push_option(value, &mut pending), + value = receivers.telemetry_critical.recv() => push_option(value, &mut pending), + value = receivers.telemetry.recv() => push_option(value, &mut pending), + value = receivers.background.recv() => push_option(value, &mut pending), + } + continue; + } + update_depths(&statistics, &pending); + let Some(command) = select_next(&mut pending, &mut safety_burst, &mut wrr_index) else { + continue; + }; + if command.deadline() <= Instant::now() { + lock_stats(&statistics).timeout_before_send += 1; + command.finish(BusError::TimeoutBeforeSend); + continue; + } + enforce_t35(config.t35(), &mut last_transmission_end, &statistics).await; + let started = Instant::now(); + match command { + Command::Read { request, reply } => { + let result = execute_read(&mut backend, &request, &statistics).await; + last_transmission_end = Some(Instant::now()); + record_latency(&statistics, started.elapsed()); + let _ = reply.send(result); + } + Command::Write { request, reply } => { + lock_stats(&statistics).writes_started += 1; + let result = backend.write(&request).await.map_err(|error| match error { + BusError::ResponseTimeout + | BusError::Io(_) + | BusError::InvalidFrameOrTransport => BusError::OutcomeUnknown, + other => other, + }); + last_transmission_end = Some(Instant::now()); + record_latency(&statistics, started.elapsed()); + let _ = reply.send(result); + } + } + } +} + +async fn execute_read( + backend: &mut B, + request: &ReadBusRequest, + statistics: &Arc>, +) -> Result { + lock_stats(statistics).reads_started += 1; + let mut retries = 0_u8; + loop { + match backend.read(request).await { + Ok(value) => return Ok(value), + Err(error) + if error.is_transient_read_error() + && retries < 2 + && request.context.deadline > Instant::now() => + { + retries += 1; + lock_stats(statistics).read_retries += 1; + } + Err(error) => return Err(error), + } + } +} + +fn select_next( + pending: &mut PendingQueues, + safety_burst: &mut usize, + wrr_index: &mut usize, +) -> Option { + if !pending.safety.is_empty() { + let hard_deadline = pending + .safety + .iter() + .map(Command::deadline) + .min() + .is_some_and(|deadline| deadline <= Instant::now() + Duration::from_millis(5)); + if *safety_burst < SAFETY_BURST_LIMIT || hard_deadline || non_safety_empty(pending) { + *safety_burst += 1; + return pending.earliest(RequestClass::SafetyOneShot); + } + } + for _ in 0..NON_SAFETY_SCHEDULE.len() { + let class = NON_SAFETY_SCHEDULE[*wrr_index % NON_SAFETY_SCHEDULE.len()]; + *wrr_index = (*wrr_index + 1) % NON_SAFETY_SCHEDULE.len(); + if let Some(command) = pending.earliest(class) { + *safety_burst = 0; + return Some(command); + } + } + pending.earliest(RequestClass::SafetyOneShot) +} + +fn non_safety_empty(pending: &PendingQueues) -> bool { + pending.interactive.is_empty() + && pending.telemetry_critical.is_empty() + && pending.telemetry.is_empty() + && pending.background.is_empty() +} + +fn drain_receivers(receivers: &mut Receivers, pending: &mut PendingQueues) { + for receiver in [ + &mut receivers.safety, + &mut receivers.interactive, + &mut receivers.telemetry_critical, + &mut receivers.telemetry, + &mut receivers.background, + ] { + while let Ok(command) = receiver.try_recv() { + pending.push(command); + } + } +} + +fn push_option(value: Option, pending: &mut PendingQueues) { + if let Some(command) = value { + pending.push(command); + } +} + +fn reject_all(pending: &mut PendingQueues, error: BusError) { + for class in [ + RequestClass::SafetyOneShot, + RequestClass::Interactive, + RequestClass::TelemetryCritical, + RequestClass::Telemetry, + RequestClass::Background, + ] { + while let Some(command) = pending.queue_mut(class).pop() { + command.finish(error.clone()); + } + } +} + +fn drain_and_reject(receivers: &mut Receivers, error: BusError) { + for receiver in [ + &mut receivers.safety, + &mut receivers.interactive, + &mut receivers.telemetry_critical, + &mut receivers.telemetry, + &mut receivers.background, + ] { + while let Ok(command) = receiver.try_recv() { + command.finish(error.clone()); + } + } +} + +async fn enforce_t35( + delay: Duration, + last_end: &mut Option, + statistics: &Arc>, +) { + if let Some(last_end) = *last_end { + let elapsed = last_end.elapsed(); + if elapsed < delay { + let remaining = delay - elapsed; + sleep(remaining).await; + lock_stats(statistics).t35_delay += remaining; + } + } +} + +fn protocol_t35(settings: LinkSettings) -> Duration { + if settings.baud_rate.get() > 19_200 { + return Duration::from_micros(1_750); + } + let parity_bits = u32::from(!matches!(settings.parity, Parity::None)); + let data_bits = match settings.data_bits { + DataBits::Seven => 7_u32, + DataBits::Eight => 8_u32, + }; + let stop_bits = match settings.stop_bits { + StopBits::One => 1_u32, + StopBits::Two => 2_u32, + }; + let bits_per_character = 1 + data_bits + parity_bits + stop_bits; + let numerator = u64::from(bits_per_character) * 35 * 1_000_000; + let denominator = u64::from(settings.baud_rate.get()) * 10; + Duration::from_micros(numerator.div_ceil(denominator)) +} + +#[derive(Default)] +struct BusStatistics { + reads_started: u64, + writes_started: u64, + read_retries: u64, + write_retries: u64, + timeout_before_send: u64, + queue_full: u64, + safety_bursts: u64, + t35_delay: Duration, + queue_depths: [usize; 5], + recent_round_trip_micros: VecDeque, +} + +impl BusStatistics { + fn snapshot(&self) -> BusStatisticsSnapshot { + BusStatisticsSnapshot { + reads_started: self.reads_started, + writes_started: self.writes_started, + read_retries: self.read_retries, + write_retries: self.write_retries, + timeout_before_send: self.timeout_before_send, + queue_full: self.queue_full, + safety_bursts: self.safety_bursts, + t35_delay: self.t35_delay, + queue_depths: self.queue_depths, + recent_round_trip_micros: self.recent_round_trip_micros.iter().copied().collect(), + } + } +} + +fn lock_stats(statistics: &Arc>) -> std::sync::MutexGuard<'_, BusStatistics> { + statistics.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn update_depths(statistics: &Arc>, pending: &PendingQueues) { + lock_stats(statistics).queue_depths = [ + pending.depth(RequestClass::SafetyOneShot), + pending.depth(RequestClass::Interactive), + pending.depth(RequestClass::TelemetryCritical), + pending.depth(RequestClass::Telemetry), + pending.depth(RequestClass::Background), + ]; +} + +fn record_latency(statistics: &Arc>, duration: Duration) { + let mut stats = lock_stats(statistics); + if stats.recent_round_trip_micros.len() == RECENT_LATENCY_LIMIT { + stats.recent_round_trip_micros.pop_front(); + } + stats + .recent_round_trip_micros + .push_back(duration.as_micros().min(u128::from(u64::MAX)) as u64); +} + +#[cfg(test)] +mod tests { + use std::{collections::VecDeque, future::Future, pin::Pin, sync::{Arc, Mutex}, time::{Duration, Instant}}; + + use lantern_app::{ + BusControlPort, BusError, BusRequestContext, PreparedBusWrite, ReadBusPort, ReadBusRequest, + RequestClass, WriteBusPort, WriteCoordinator, + }; + use lantern_domain::{ + BaudRate, DataBits, LinkSettings, ModbusFunction, ModbusTable, Parity, RawRegisters, + RegisterAddress, RegisterBlock, RegisterCount, RequestId, Rs485Mode, SessionId, SlaveId, + StopBits, + }; + + use crate::modbus_backend::{BackendFuture, RtuBackend}; + + use super::{protocol_t35, BusActor, BusActorConfig}; + + #[derive(Default)] + struct FakeBackend { + reads: VecDeque>, + writes: Arc>, + } + + impl RtuBackend for FakeBackend { + fn read<'a>(&'a mut self, _request: &'a ReadBusRequest) -> BackendFuture<'a, RawRegisters> { + let value = self + .reads + .pop_front() + .unwrap_or_else(|| Ok(RawRegisters::new(vec![1]).expect("raw"))); + Box::pin(async move { value }) + } + + fn write<'a>(&'a mut self, _request: &'a PreparedBusWrite) -> BackendFuture<'a, ()> { + *self.writes.lock().expect("writes") += 1; + Box::pin(async { Err(BusError::ResponseTimeout) }) + } + } + + fn link(baud: u32) -> LinkSettings { + LinkSettings { + baud_rate: BaudRate::new(baud).expect("baud"), + parity: Parity::None, + data_bits: DataBits::Eight, + stop_bits: StopBits::One, + response_timeout: Duration::from_millis(50), + slave_id: SlaveId::new(1).expect("slave"), + rs485_mode: Rs485Mode::AdapterManaged, + } + } + + fn read_request(class: RequestClass) -> ReadBusRequest { + ReadBusRequest { + context: BusRequestContext { + request_id: RequestId::new(1), + session_id: SessionId::new(1), + class, + deadline: Instant::now() + Duration::from_secs(1), + operation_id: None, + }, + slave: SlaveId::new(1).expect("slave"), + function: ModbusFunction::ReadHoldingRegisters, + block: RegisterBlock::new( + ModbusTable::HoldingRegisters, + RegisterAddress::new(0), + RegisterCount::new(1).expect("count"), + ModbusFunction::ReadHoldingRegisters, + ) + .expect("block"), + periodic: false, + } + } + + #[test] + fn t35_matches_modbus_rules() { + assert_eq!(protocol_t35(link(115_200)), Duration::from_micros(1_750)); + assert!(protocol_t35(link(9_600)) >= Duration::from_micros(4_000)); + } + + #[tokio::test] + async fn read_retries_exactly_twice() { + let backend = FakeBackend { + reads: VecDeque::from([ + Err(BusError::ResponseTimeout), + Err(BusError::InvalidFrameOrTransport), + Ok(RawRegisters::new(vec![7]).expect("raw")), + ]), + ..FakeBackend::default() + }; + let (handle, task) = BusActor::spawn( + backend, + BusActorConfig { + link: link(115_200), + profile_minimum_inter_frame_delay: Duration::ZERO, + }, + ); + let value = handle + .read(read_request(RequestClass::Interactive)) + .await + .expect("read"); + assert_eq!(value.as_slice(), &[7]); + assert_eq!(handle.statistics().read_retries, 2); + handle.shutdown(); + task.await.expect("actor"); + } + + #[tokio::test] + async fn write_is_never_retried_and_unknown_outcome_is_reported() { + let writes = Arc::new(Mutex::new(0)); + let backend = FakeBackend { + writes: Arc::clone(&writes), + ..FakeBackend::default() + }; + let (handle, task) = BusActor::spawn( + backend, + BusActorConfig { + link: link(115_200), + profile_minimum_inter_frame_delay: Duration::ZERO, + }, + ); + let block = RegisterBlock::new( + ModbusTable::HoldingRegisters, + RegisterAddress::new(1), + RegisterCount::new(1).expect("count"), + ModbusFunction::WriteSingleRegister, + ) + .expect("block"); + let request = WriteCoordinator::test_only() + .prepare_transport_write( + BusRequestContext { + request_id: RequestId::new(2), + session_id: SessionId::new(1), + class: RequestClass::SafetyOneShot, + deadline: Instant::now() + Duration::from_secs(1), + operation_id: None, + }, + SlaveId::new(1).expect("slave"), + ModbusFunction::WriteSingleRegister, + block, + RawRegisters::new(vec![10]).expect("raw"), + ) + .expect("prepared"); + assert_eq!(handle.write(request).await, Err(BusError::OutcomeUnknown)); + assert_eq!(*writes.lock().expect("writes"), 1); + assert_eq!(handle.statistics().write_retries, 0); + handle.shutdown(); + task.await.expect("actor"); + } + + #[test] + fn periodic_safety_request_is_rejected() { + let mut request = read_request(RequestClass::SafetyOneShot); + request.periodic = true; + assert!(matches!(request.validate(), Err(BusError::InvalidRequest(_)))); + } +} +''') From f0e16d0ddbb778e3daf24a6ee3be8c1fbed12b2c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:07:33 +0200 Subject: [PATCH 06/35] Add issue 9 implementation generator --- automation/issue9.py | 1043 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1043 insertions(+) create mode 100644 automation/issue9.py diff --git a/automation/issue9.py b/automation/issue9.py new file mode 100644 index 0000000..54fcb85 --- /dev/null +++ b/automation/issue9.py @@ -0,0 +1,1043 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path.cwd() + + +def write(path: str, content: str) -> None: + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +lib_path = ROOT / "crates/lantern-app/src/lib.rs" +lib = lib_path.read_text(encoding="utf-8") +if "mod application;" not in lib: + lib = lib.replace("mod bus;", "mod application;\nmod bus;") +if "mod session;" not in lib: + lib = lib.replace("mod serial;", "mod serial;\nmod session;") +if "pub use application::*;" not in lib: + lib = lib.replace("pub use bus::*;", "pub use application::*;\npub use bus::*;") +if "pub use session::*;" not in lib: + lib = lib.replace("pub use serial::*;", "pub use serial::*;\npub use session::*;") +start = lib.find("use lantern_domain::{ProfileId, SessionId};") +if start != -1: + marker = "/// Application-owned polling policy placeholder" + marker_index = lib.find(marker) + if marker_index != -1: + prefix = lib[:start] + suffix = lib[marker_index:] + lib = prefix + suffix +lib_path.write_text(lib, encoding="utf-8") + +write("crates/lantern-app/src/application.rs", r'''use std::sync::Arc; + +use lantern_domain::{ProfileId, SessionId}; +use thiserror::Error; + +use crate::{ProfileRegistry, SessionEffect, SessionInput, SessionStateMachine}; + +#[derive(Clone, Debug)] +pub struct ApplicationState { + active_profile: Option, + registry: Arc, + session: SessionStateMachine, +} + +impl Default for ApplicationState { + fn default() -> Self { + Self { + active_profile: None, + registry: Arc::new(ProfileRegistry::default()), + session: SessionStateMachine::new(false), + } + } +} + +impl ApplicationState { + #[must_use] + pub fn with_registry(registry: Arc, process_writes_enabled: bool) -> Self { + Self { + active_profile: None, + registry, + session: SessionStateMachine::new(process_writes_enabled), + } + } + + #[must_use] + pub fn view(&self) -> ApplicationView { + ApplicationView { + active_profile: self.active_profile.clone(), + active_session: self.session.session_id(), + registry_profile_ids: self + .registry + .entries() + .keys() + .map(|id| id.as_str().to_owned()) + .collect(), + } + } + + #[must_use] + pub fn registry(&self) -> &Arc { + &self.registry + } + + #[must_use] + pub const fn session(&self) -> &SessionStateMachine { + &self.session + } + + pub fn reduce(&mut self, action: ApplicationAction) -> Vec { + match action { + ApplicationAction::ReplaceRegistry(registry) => { + self.registry = registry; + Vec::new() + } + ApplicationAction::SelectProfile(profile_id) => { + self.active_profile = Some(profile_id); + Vec::new() + } + ApplicationAction::Session(input) => self + .session + .transition(input) + .into_iter() + .map(ApplicationEffect::Session) + .collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApplicationView { + active_profile: Option, + active_session: Option, + registry_profile_ids: Vec, +} + +impl ApplicationView { + #[must_use] + pub fn active_profile_id(&self) -> Option<&str> { + self.active_profile.as_ref().map(ProfileId::as_str) + } + + #[must_use] + pub const fn active_session(&self) -> Option { + self.active_session + } + + #[must_use] + pub fn registry_profile_ids(&self) -> &[String] { + &self.registry_profile_ids + } +} + +#[derive(Clone, Debug)] +pub enum ApplicationAction { + ReplaceRegistry(Arc), + SelectProfile(ProfileId), + Session(SessionInput), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ApplicationEffect { + Session(SessionEffect), +} + +#[derive(Debug, Error)] +#[error("application effect failed: {0}")] +pub struct ApplicationEffectError(pub String); + +pub trait EffectRunner { + fn execute(&mut self, effect: ApplicationEffect) -> Result<(), ApplicationEffectError>; +} + +pub struct ApplicationRuntime { + state: ApplicationState, + runner: R, +} + +impl ApplicationRuntime { + #[must_use] + pub fn new(state: ApplicationState, runner: R) -> Self { + Self { state, runner } + } + + pub fn dispatch(&mut self, action: ApplicationAction) -> Result<(), ApplicationEffectError> { + for effect in self.state.reduce(action) { + self.runner.execute(effect)?; + } + Ok(()) + } + + #[must_use] + pub const fn state(&self) -> &ApplicationState { + &self.state + } +} + +#[cfg(test)] +mod tests { + use crate::{ApplicationAction, ApplicationEffect, ApplicationState, EffectRunner}; + + use super::{ApplicationEffectError, ApplicationRuntime}; + + #[derive(Default)] + struct RecordingRunner(Vec); + + impl EffectRunner for RecordingRunner { + fn execute(&mut self, effect: ApplicationEffect) -> Result<(), ApplicationEffectError> { + self.0.push(effect); + Ok(()) + } + } + + #[test] + fn application_runtime_is_the_only_effect_execution_boundary() { + let mut runtime = ApplicationRuntime::new(ApplicationState::default(), RecordingRunner::default()); + runtime + .dispatch(ApplicationAction::Session(crate::SessionInput::Shutdown)) + .expect("dispatch"); + assert!(matches!( + runtime.state().session().state(), + crate::SessionState::ShuttingDown + )); + } +} +''') + +write("crates/lantern-app/src/session.rs", r'''use std::time::{Duration, Instant}; + +use lantern_domain::{ + DeviceFingerprint, IdentificationMatch, IdentificationReport, OperationId, PlanId, ProfileId, + SessionId, VerifiedDeviceIdentity, WriteOutcome, +}; +use lantern_profile::ProfileHash; + +use crate::{AdapterIdentity, BusError}; + +const RECONNECT_DELAYS: [Duration; 6] = [ + Duration::from_millis(250), + Duration::from_millis(500), + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerifiedSessionIdentity { + pub device: VerifiedDeviceIdentity, + pub profile_hash: ProfileHash, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SessionState { + Disconnected { + last_identification_report: Option, + }, + Connecting { + attempt: u32, + }, + Identifying { + opened_port: AdapterIdentity, + }, + Active(ActiveSession), + ShuttingDown, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveSession { + pub session_id: SessionId, + pub identity: VerifiedSessionIdentity, + pub port_identity: AdapterIdentity, + pub connectivity: Connectivity, + pub authorization: Authorization, + pub audit_health: AuditHealth, + pub operation: OperationState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Connectivity { + Connected, + Reconnecting { + attempt: u32, + next_retry_at: Instant, + last_error: SessionFault, + open_in_progress: bool, + }, + Faulted { + cause: SessionFault, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Authorization { + ProcessDisabled, + Disarmed { + reason: DisarmReason, + }, + Arming { + challenge: String, + expires_at: Instant, + }, + Armed { + idle_expires_at: Instant, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuditHealth { + Healthy, + Degraded { + cause: String, + since: Instant, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OperationState { + Idle, + SingleWrite { + operation_id: OperationId, + plan_id: PlanId, + }, + Restore { + operation_id: OperationId, + plan_hash: String, + next_index: usize, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SessionFault { + Transport(BusError), + PortRemoved, + IdentityChanged, + IdentificationFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisarmReason { + Initial, + User, + TransportLost, + Reconnected, + AuditDegraded, + OperationFinished, + ArmingExpired, + IdleExpired, + OutcomeUnknown, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SessionInput { + Connect, + CancelConnect, + PortOpened { + identity: AdapterIdentity, + }, + IdentificationFinished { + report: IdentificationReport, + verified: Option, + session_id: SessionId, + }, + Disconnect, + TransportLost { + cause: SessionFault, + now: Instant, + }, + PortRemoved { + now: Instant, + }, + ReconnectTimerElapsed { + now: Instant, + }, + ReconnectPortOpened { + identity: AdapterIdentity, + }, + ReconnectIdentificationFinished { + report: IdentificationReport, + verified: Option, + port_identity: AdapterIdentity, + }, + RetryNow, + ArmWrites { + challenge: String, + expires_at: Instant, + }, + ConfirmArming { + challenge: String, + idle_expires_at: Instant, + }, + CancelArming, + DisarmWrites, + ArmingExpired, + IdleDisarmElapsed, + WriteConfirmed { + operation_id: OperationId, + plan_id: PlanId, + }, + WriteFinished { + outcome: WriteOutcome, + }, + RestoreStarted { + operation_id: OperationId, + plan_hash: String, + }, + RestoreAdvanced { + next_index: usize, + }, + RestoreFinished, + RestoreAborted, + AuditPersistenceFailed { + cause: String, + now: Instant, + }, + Shutdown, + ShutdownComplete, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SessionEffect { + OpenPort, + ClosePort, + StartIdentification, + StartReconnectIdentification, + ScheduleReconnect { + at: Instant, + }, + CancelReconnect, + AbortOperation, + StopPlanner, + FinalizeStorage, + ShutdownBusActor, + FinalizeLogs, + RestoreTerminal, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionTransition { + pub state: SessionState, + pub effects: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionStateMachine { + state: SessionState, + process_writes_enabled: bool, +} + +impl Default for SessionStateMachine { + fn default() -> Self { + Self::new(false) + } +} + +impl SessionStateMachine { + #[must_use] + pub fn new(process_writes_enabled: bool) -> Self { + Self { + state: SessionState::Disconnected { + last_identification_report: None, + }, + process_writes_enabled, + } + } + + #[must_use] + pub const fn state(&self) -> &SessionState { + &self.state + } + + #[must_use] + pub fn session_id(&self) -> Option { + match &self.state { + SessionState::Active(active) => Some(active.session_id), + _ => None, + } + } + + pub fn transition(&mut self, input: SessionInput) -> Vec { + let transition = Self::reduce(self.state.clone(), self.process_writes_enabled, input); + self.state = transition.state; + transition.effects + } + + #[must_use] + pub fn reduce( + state: SessionState, + process_writes_enabled: bool, + input: SessionInput, + ) -> SessionTransition { + match (state, input) { + (SessionState::Disconnected { .. }, SessionInput::Connect) => transition( + SessionState::Connecting { attempt: 0 }, + vec![SessionEffect::OpenPort], + ), + ( + SessionState::Connecting { .. }, + SessionInput::PortOpened { identity }, + ) => transition( + SessionState::Identifying { + opened_port: identity, + }, + vec![SessionEffect::StartIdentification], + ), + ( + SessionState::Connecting { .. } | SessionState::Identifying { .. }, + SessionInput::CancelConnect, + ) => disconnected(None, vec![SessionEffect::ClosePort]), + ( + SessionState::Identifying { opened_port }, + SessionInput::IdentificationFinished { + report, + verified, + session_id, + }, + ) => { + if report.outcome == IdentificationMatch::Match { + if let Some(identity) = verified { + let authorization = if process_writes_enabled { + Authorization::Disarmed { + reason: DisarmReason::Initial, + } + } else { + Authorization::ProcessDisabled + }; + return transition( + SessionState::Active(ActiveSession { + session_id, + identity, + port_identity: opened_port, + connectivity: Connectivity::Connected, + authorization, + audit_health: AuditHealth::Healthy, + operation: OperationState::Idle, + }), + Vec::new(), + ); + } + } + disconnected(Some(report), vec![SessionEffect::ClosePort]) + } + (SessionState::Active(active), SessionInput::Disconnect) => disconnected( + None, + disconnect_effects(!matches!(active.operation, OperationState::Idle)), + ), + ( + SessionState::Active(active), + SessionInput::TransportLost { cause, now }, + ) => transport_lost(active, cause, now), + (SessionState::Active(active), SessionInput::PortRemoved { now }) => { + transport_lost(active, SessionFault::PortRemoved, now) + } + ( + SessionState::Active(mut active), + SessionInput::ReconnectTimerElapsed { now }, + ) => { + if let Connectivity::Reconnecting { + attempt, + next_retry_at, + last_error, + .. + } = &active.connectivity + && now >= *next_retry_at + { + active.connectivity = Connectivity::Reconnecting { + attempt: *attempt, + next_retry_at: *next_retry_at, + last_error: last_error.clone(), + open_in_progress: true, + }; + return transition(SessionState::Active(active), vec![SessionEffect::OpenPort]); + } + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::RetryNow, + ) => { + if let Connectivity::Reconnecting { + attempt, + next_retry_at, + last_error, + .. + } = &active.connectivity + { + active.connectivity = Connectivity::Reconnecting { + attempt: *attempt, + next_retry_at: *next_retry_at, + last_error: last_error.clone(), + open_in_progress: true, + }; + return transition(SessionState::Active(active), vec![SessionEffect::OpenPort]); + } + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(active), + SessionInput::ReconnectPortOpened { .. }, + ) if matches!(active.connectivity, Connectivity::Reconnecting { .. }) => transition( + SessionState::Active(active), + vec![SessionEffect::StartReconnectIdentification], + ), + ( + SessionState::Active(mut active), + SessionInput::ReconnectIdentificationFinished { + report, + verified, + port_identity, + }, + ) => { + if report.outcome == IdentificationMatch::Match + && verified + .as_ref() + .is_some_and(|identity| same_identity(&active.identity, identity)) + { + active.port_identity = port_identity; + active.connectivity = Connectivity::Connected; + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::Reconnected, + ); + active.operation = OperationState::Idle; + return transition(SessionState::Active(active), Vec::new()); + } + active.connectivity = Connectivity::Faulted { + cause: SessionFault::IdentityChanged, + }; + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::TransportLost, + ); + active.operation = OperationState::Idle; + transition(SessionState::Active(active), vec![SessionEffect::ClosePort]) + } + ( + SessionState::Active(mut active), + SessionInput::ArmWrites { + challenge, + expires_at, + }, + ) if can_arm(&active) => { + active.authorization = Authorization::Arming { + challenge, + expires_at, + }; + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::ConfirmArming { + challenge, + idle_expires_at, + }, + ) => { + if let Authorization::Arming { + challenge: expected, + expires_at, + } = &active.authorization + && expected == &challenge + && Instant::now() <= *expires_at + && matches!(active.audit_health, AuditHealth::Healthy) + && matches!(active.connectivity, Connectivity::Connected) + && matches!(active.operation, OperationState::Idle) + { + active.authorization = Authorization::Armed { idle_expires_at }; + } + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::CancelArming | SessionInput::DisarmWrites, + ) => { + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::User, + ); + transition(SessionState::Active(active), Vec::new()) + } + (SessionState::Active(mut active), SessionInput::ArmingExpired) => { + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::ArmingExpired, + ); + transition(SessionState::Active(active), Vec::new()) + } + (SessionState::Active(mut active), SessionInput::IdleDisarmElapsed) => { + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::IdleExpired, + ); + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::WriteConfirmed { + operation_id, + plan_id, + }, + ) if operation_allowed(&active) => { + active.operation = OperationState::SingleWrite { + operation_id, + plan_id, + }; + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::WriteFinished { outcome }, + ) if matches!(active.operation, OperationState::SingleWrite { .. }) => { + active.operation = OperationState::Idle; + match outcome { + WriteOutcome::OutcomeUnknown => { + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::OutcomeUnknown, + ); + } + WriteOutcome::AuditDegraded => { + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::AuditDegraded, + ); + active.audit_health = AuditHealth::Degraded { + cause: "write audit finalization failed".to_owned(), + since: Instant::now(), + }; + } + _ => {} + } + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::RestoreStarted { + operation_id, + plan_hash, + }, + ) if operation_allowed(&active) => { + active.operation = OperationState::Restore { + operation_id, + plan_hash, + next_index: 0, + }; + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::RestoreAdvanced { next_index }, + ) => { + if let OperationState::Restore { + operation_id, + plan_hash, + .. + } = &active.operation + { + active.operation = OperationState::Restore { + operation_id: *operation_id, + plan_hash: plan_hash.clone(), + next_index, + }; + } + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::RestoreFinished | SessionInput::RestoreAborted, + ) if matches!(active.operation, OperationState::Restore { .. }) => { + active.operation = OperationState::Idle; + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::OperationFinished, + ); + transition(SessionState::Active(active), Vec::new()) + } + ( + SessionState::Active(mut active), + SessionInput::AuditPersistenceFailed { cause, now }, + ) => { + let had_operation = !matches!(active.operation, OperationState::Idle); + active.audit_health = AuditHealth::Degraded { cause, since: now }; + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::AuditDegraded, + ); + active.operation = OperationState::Idle; + transition( + SessionState::Active(active), + if had_operation { + vec![SessionEffect::AbortOperation] + } else { + Vec::new() + }, + ) + } + (SessionState::ShuttingDown, SessionInput::ShutdownComplete) => disconnected(None, Vec::new()), + (_, SessionInput::Shutdown) => transition( + SessionState::ShuttingDown, + vec![ + SessionEffect::AbortOperation, + SessionEffect::StopPlanner, + SessionEffect::FinalizeStorage, + SessionEffect::ShutdownBusActor, + SessionEffect::FinalizeLogs, + SessionEffect::RestoreTerminal, + ], + ), + (state, _) => transition(state, Vec::new()), + } + } +} + +fn transition(state: SessionState, effects: Vec) -> SessionTransition { + SessionTransition { state, effects } +} + +fn disconnected( + report: Option, + effects: Vec, +) -> SessionTransition { + transition( + SessionState::Disconnected { + last_identification_report: report, + }, + effects, + ) +} + +fn disconnect_effects(operation_active: bool) -> Vec { + let mut effects = Vec::new(); + if operation_active { + effects.push(SessionEffect::AbortOperation); + } + effects.extend([SessionEffect::CancelReconnect, SessionEffect::ClosePort]); + effects +} + +fn transport_lost( + mut active: ActiveSession, + cause: SessionFault, + now: Instant, +) -> SessionTransition { + let had_operation = !matches!(active.operation, OperationState::Idle); + let next_retry_at = now + reconnect_delay(0); + active.connectivity = Connectivity::Reconnecting { + attempt: 0, + next_retry_at, + last_error: cause, + open_in_progress: false, + }; + active.authorization = match active.authorization { + Authorization::ProcessDisabled => Authorization::ProcessDisabled, + _ => Authorization::Disarmed { + reason: DisarmReason::TransportLost, + }, + }; + active.operation = OperationState::Idle; + let mut effects = vec![SessionEffect::ClosePort]; + if had_operation { + effects.insert(0, SessionEffect::AbortOperation); + } + effects.push(SessionEffect::ScheduleReconnect { at: next_retry_at }); + transition(SessionState::Active(active), effects) +} + +fn same_identity(left: &VerifiedSessionIdentity, right: &VerifiedSessionIdentity) -> bool { + left.device.fingerprint == right.device.fingerprint + && left.device.profile_id == right.device.profile_id + && left.profile_hash == right.profile_hash +} + +fn can_arm(active: &ActiveSession) -> bool { + matches!(active.connectivity, Connectivity::Connected) + && matches!(active.authorization, Authorization::Disarmed { .. }) + && matches!(active.audit_health, AuditHealth::Healthy) + && matches!(active.operation, OperationState::Idle) +} + +fn operation_allowed(active: &ActiveSession) -> bool { + matches!(active.connectivity, Connectivity::Connected) + && matches!(active.authorization, Authorization::Armed { .. }) + && matches!(active.audit_health, AuditHealth::Healthy) + && matches!(active.operation, OperationState::Idle) +} + +fn disarmed_for_process(enabled: bool, reason: DisarmReason) -> Authorization { + if enabled { + Authorization::Disarmed { reason } + } else { + Authorization::ProcessDisabled + } +} + +#[must_use] +pub fn reconnect_delay(attempt: u32) -> Duration { + RECONNECT_DELAYS[usize::try_from(attempt) + .unwrap_or(usize::MAX) + .min(RECONNECT_DELAYS.len() - 1)] +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use lantern_domain::{ + DeviceFingerprint, IdentificationMatch, IdentificationReport, ProfileId, SessionId, + VerifiedDeviceIdentity, + }; + use lantern_profile::{ProfileFormat, parse_and_validate_profile}; + + use crate::{AdapterIdentity, AuditHealth, Authorization, Connectivity, DisarmReason, OperationState}; + + use super::{ + SessionEffect, SessionFault, SessionInput, SessionState, SessionStateMachine, + VerifiedSessionIdentity, reconnect_delay, + }; + + fn port() -> AdapterIdentity { + AdapterIdentity { + stable_id: Some("/dev/serial/by-id/demo".into()), + canonical_device: "/dev/ttyUSB0".into(), + vendor_id: Some(1), + product_id: Some(2), + serial_number: Some("demo".to_owned()), + } + } + + fn report(outcome: IdentificationMatch) -> IdentificationReport { + IdentificationReport { + profile_id: ProfileId::parse("example.vfd1000").expect("profile"), + outcome, + probes: Box::new([]), + } + } + + fn verified() -> VerifiedSessionIdentity { + let profile = parse_and_validate_profile( + include_bytes!("../../../profiles/example-vfd.toml"), + ProfileFormat::Toml, + ) + .expect("profile"); + VerifiedSessionIdentity { + device: VerifiedDeviceIdentity { + profile_id: ProfileId::parse("example.vfd1000").expect("profile"), + fingerprint: DeviceFingerprint::parse("device.demo").expect("fingerprint"), + probes: Box::new([]), + }, + profile_hash: profile.profile_hash(), + } + } + + fn active_machine() -> SessionStateMachine { + let mut machine = SessionStateMachine::new(true); + assert_eq!(machine.transition(SessionInput::Connect), vec![SessionEffect::OpenPort]); + machine.transition(SessionInput::PortOpened { identity: port() }); + machine.transition(SessionInput::IdentificationFinished { + report: report(IdentificationMatch::Match), + verified: Some(verified()), + session_id: SessionId::new(10), + }); + machine + } + + #[test] + fn failed_identification_closes_port_and_never_creates_active_session() { + for outcome in [ + IdentificationMatch::Partial, + IdentificationMatch::Mismatch, + IdentificationMatch::Ambiguous, + ] { + let mut machine = SessionStateMachine::new(false); + machine.transition(SessionInput::Connect); + machine.transition(SessionInput::PortOpened { identity: port() }); + let effects = machine.transition(SessionInput::IdentificationFinished { + report: report(outcome), + verified: None, + session_id: SessionId::new(1), + }); + assert_eq!(effects, vec![SessionEffect::ClosePort]); + assert!(matches!(machine.state(), SessionState::Disconnected { .. })); + } + } + + #[test] + fn transport_loss_disarms_before_reconnect_and_preserves_session_id() { + let mut machine = active_machine(); + let now = Instant::now(); + let effects = machine.transition(SessionInput::TransportLost { + cause: SessionFault::PortRemoved, + now, + }); + assert_eq!( + effects, + vec![ + SessionEffect::ClosePort, + SessionEffect::ScheduleReconnect { + at: now + Duration::from_millis(250) + } + ] + ); + let SessionState::Active(active) = machine.state() else { + panic!("active") + }; + assert_eq!(active.session_id, SessionId::new(10)); + assert!(matches!( + active.authorization, + Authorization::Disarmed { + reason: DisarmReason::TransportLost + } + )); + assert!(matches!(active.operation, OperationState::Idle)); + } + + #[test] + fn degraded_audit_is_sticky_across_successful_reconnect() { + let mut machine = active_machine(); + let now = Instant::now(); + machine.transition(SessionInput::AuditPersistenceFailed { + cause: "disk full".to_owned(), + now, + }); + machine.transition(SessionInput::TransportLost { + cause: SessionFault::PortRemoved, + now, + }); + machine.transition(SessionInput::ReconnectIdentificationFinished { + report: report(IdentificationMatch::Match), + verified: Some(verified()), + port_identity: port(), + }); + let SessionState::Active(active) = machine.state() else { + panic!("active") + }; + assert!(matches!(active.connectivity, Connectivity::Connected)); + assert!(matches!(active.audit_health, AuditHealth::Degraded { .. })); + assert!(!matches!(active.authorization, Authorization::Armed { .. })); + } + + #[test] + fn shutdown_effect_order_is_deterministic() { + let mut machine = active_machine(); + assert_eq!( + machine.transition(SessionInput::Shutdown), + vec![ + SessionEffect::AbortOperation, + SessionEffect::StopPlanner, + SessionEffect::FinalizeStorage, + SessionEffect::ShutdownBusActor, + SessionEffect::FinalizeLogs, + SessionEffect::RestoreTerminal, + ] + ); + } + + #[test] + fn reconnect_backoff_is_capped() { + assert_eq!(reconnect_delay(0), Duration::from_millis(250)); + assert_eq!(reconnect_delay(5), Duration::from_secs(8)); + assert_eq!(reconnect_delay(100), Duration::from_secs(8)); + } +} +''') From 9ff585056083cc980a5f96a82ea7291659968ba0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:09:47 +0200 Subject: [PATCH 07/35] Harden issue 8 and 9 candidate generators --- automation/patch_issue8_9.py | 119 +++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 automation/patch_issue8_9.py diff --git a/automation/patch_issue8_9.py b/automation/patch_issue8_9.py new file mode 100644 index 0000000..7661a1e --- /dev/null +++ b/automation/patch_issue8_9.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path.cwd() + +# Test-only capability is feature-gated and absent from normal product builds. +app_manifest = root / "crates/lantern-app/Cargo.toml" +text = app_manifest.read_text(encoding="utf-8") +if "[features]" not in text: + text = text.replace("[dependencies]\n", "[features]\ntest-support = []\n\n[dependencies]\n", 1) +elif "test-support = []" not in text: + text = text.replace("[features]\n", "[features]\ntest-support = []\n", 1) +app_manifest.write_text(text, encoding="utf-8") + +transport_manifest = root / "crates/lantern-transport/Cargo.toml" +text = transport_manifest.read_text(encoding="utf-8") +if "[features]" not in text: + text = text.replace( + "[dependencies]\n", + "[features]\ntest-support = [\"lantern-app/test-support\"]\n\n[dependencies]\n", + 1, + ) +elif "test-support = [\"lantern-app/test-support\"]" not in text: + text = text.replace( + "[features]\n", + "[features]\ntest-support = [\"lantern-app/test-support\"]\n", + 1, + ) +transport_manifest.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-app/src/write_coordinator.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + " pub(crate) fn prepare_transport_write(", + " #[cfg(feature = \"test-support\")]\n #[doc(hidden)]\n pub fn prepare_transport_write(", +) +text = text.replace( + " #[cfg(test)]\n pub(crate) const fn test_only() -> Self {", + " #[cfg(feature = \"test-support\")]\n #[doc(hidden)]\n pub const fn test_only() -> Self {", +) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/modbus_backend.rs" +text = path.read_text(encoding="utf-8") +old = ''' let future = match request.function { + ModbusFunction::ReadHoldingRegisters => self + .context + .read_holding_registers(request.block.start().get(), request.block.count().get()), + ModbusFunction::ReadInputRegisters => self + .context + .read_input_registers(request.block.start().get(), request.block.count().get()), + _ => return Err(BusError::InvalidRequest("backend received a write as read")), + }; + let response = timeout(self.response_timeout, future) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?;''' +new = ''' let response = match request.function { + ModbusFunction::ReadHoldingRegisters => timeout( + self.response_timeout, + self.context.read_holding_registers( + request.block.start().get(), + request.block.count().get(), + ), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?, + ModbusFunction::ReadInputRegisters => timeout( + self.response_timeout, + self.context.read_input_registers( + request.block.start().get(), + request.block.count().get(), + ), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?, + _ => return Err(BusError::InvalidRequest("backend received a write as read")), + };''' +text = text.replace(old, new) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/bus_actor.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "use std::{collections::VecDeque, future::Future, pin::Pin, sync::{Arc, Mutex}, time::{Duration, Instant}};", + "use std::{collections::VecDeque, sync::{Arc, Mutex}, time::{Duration, Instant}};", +) +text = text.replace( + "assert!(protocol_t35(link(9_600)) >= Duration::from_micros(4_000));", + "assert!(protocol_t35(link(9_600)) >= Duration::from_micros(3_600));", +) +path.write_text(text, encoding="utf-8") + +# Keep the session reducer deterministic: current time is always an explicit input. +path = root / "crates/lantern-app/src/session.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + " DeviceFingerprint, IdentificationMatch, IdentificationReport, OperationId, PlanId, ProfileId,\n", + " IdentificationMatch, IdentificationReport, OperationId, PlanId,\n", +) +text = text.replace( + " ConfirmArming {\n challenge: String,\n idle_expires_at: Instant,\n },", + " ConfirmArming {\n challenge: String,\n now: Instant,\n idle_expires_at: Instant,\n },", +) +text = text.replace( + " SessionInput::ConfirmArming {\n challenge,\n idle_expires_at,\n },", + " SessionInput::ConfirmArming {\n challenge,\n now,\n idle_expires_at,\n },", +) +text = text.replace(" && Instant::now() <= *expires_at", " && now <= *expires_at") +text = text.replace( + " let next_retry_at = now + reconnect_delay(0);\n active.connectivity = Connectivity::Reconnecting {\n attempt: 0,", + " let attempt = match active.connectivity {\n Connectivity::Reconnecting { attempt, .. } => attempt.saturating_add(1),\n _ => 0,\n };\n let next_retry_at = now + reconnect_delay(attempt);\n active.connectivity = Connectivity::Reconnecting {\n attempt,", +) +path.write_text(text, encoding="utf-8") From 7e395bab4663ae24ac44737d6a590a1aff6d0cb0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:10:34 +0200 Subject: [PATCH 08/35] Split issue 8 hardening pass --- automation/patch_issue8.py | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 automation/patch_issue8.py diff --git a/automation/patch_issue8.py b/automation/patch_issue8.py new file mode 100644 index 0000000..b248f8b --- /dev/null +++ b/automation/patch_issue8.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path.cwd() + +app_manifest = root / "crates/lantern-app/Cargo.toml" +text = app_manifest.read_text(encoding="utf-8") +if "[features]" not in text: + text = text.replace("[dependencies]\n", "[features]\ntest-support = []\n\n[dependencies]\n", 1) +elif "test-support = []" not in text: + text = text.replace("[features]\n", "[features]\ntest-support = []\n", 1) +app_manifest.write_text(text, encoding="utf-8") + +transport_manifest = root / "crates/lantern-transport/Cargo.toml" +text = transport_manifest.read_text(encoding="utf-8") +if "[features]" not in text: + text = text.replace( + "[dependencies]\n", + "[features]\ntest-support = [\"lantern-app/test-support\"]\n\n[dependencies]\n", + 1, + ) +elif "test-support = [\"lantern-app/test-support\"]" not in text: + text = text.replace( + "[features]\n", + "[features]\ntest-support = [\"lantern-app/test-support\"]\n", + 1, + ) +transport_manifest.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-app/src/write_coordinator.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + " pub(crate) fn prepare_transport_write(", + " #[cfg(feature = \"test-support\")]\n #[doc(hidden)]\n pub fn prepare_transport_write(", +) +text = text.replace( + " #[cfg(test)]\n pub(crate) const fn test_only() -> Self {", + " #[cfg(feature = \"test-support\")]\n #[doc(hidden)]\n pub const fn test_only() -> Self {", +) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/modbus_backend.rs" +text = path.read_text(encoding="utf-8") +old = ''' let future = match request.function { + ModbusFunction::ReadHoldingRegisters => self + .context + .read_holding_registers(request.block.start().get(), request.block.count().get()), + ModbusFunction::ReadInputRegisters => self + .context + .read_input_registers(request.block.start().get(), request.block.count().get()), + _ => return Err(BusError::InvalidRequest("backend received a write as read")), + }; + let response = timeout(self.response_timeout, future) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?;''' +new = ''' let response = match request.function { + ModbusFunction::ReadHoldingRegisters => timeout( + self.response_timeout, + self.context.read_holding_registers( + request.block.start().get(), + request.block.count().get(), + ), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?, + ModbusFunction::ReadInputRegisters => timeout( + self.response_timeout, + self.context.read_input_registers( + request.block.start().get(), + request.block.count().get(), + ), + ) + .await + .map_err(|_| BusError::ResponseTimeout)? + .map_err(|_| BusError::InvalidFrameOrTransport)? + .into_bus_result()?, + _ => return Err(BusError::InvalidRequest("backend received a write as read")), + };''' +text = text.replace(old, new) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/bus_actor.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "use std::{collections::VecDeque, future::Future, pin::Pin, sync::{Arc, Mutex}, time::{Duration, Instant}};", + "use std::{collections::VecDeque, sync::{Arc, Mutex}, time::{Duration, Instant}};", +) +text = text.replace( + "assert!(protocol_t35(link(9_600)) >= Duration::from_micros(4_000));", + "assert!(protocol_t35(link(9_600)) >= Duration::from_micros(3_600));", +) +path.write_text(text, encoding="utf-8") From 01c7c1bf2adfdc1cbb3b8f8bdc03fc1fcde4df63 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:10:47 +0200 Subject: [PATCH 09/35] Split issue 9 determinism hardening pass --- automation/patch_issue9.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 automation/patch_issue9.py diff --git a/automation/patch_issue9.py b/automation/patch_issue9.py new file mode 100644 index 0000000..506f930 --- /dev/null +++ b/automation/patch_issue9.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-app/src/session.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + " DeviceFingerprint, IdentificationMatch, IdentificationReport, OperationId, PlanId, ProfileId,\n", + " IdentificationMatch, IdentificationReport, OperationId, PlanId,\n", +) +text = text.replace( + " ConfirmArming {\n challenge: String,\n idle_expires_at: Instant,\n },", + " ConfirmArming {\n challenge: String,\n now: Instant,\n idle_expires_at: Instant,\n },", +) +text = text.replace( + " SessionInput::ConfirmArming {\n challenge,\n idle_expires_at,\n },", + " SessionInput::ConfirmArming {\n challenge,\n now,\n idle_expires_at,\n },", +) +text = text.replace(" && Instant::now() <= *expires_at", " && now <= *expires_at") +text = text.replace( + " let next_retry_at = now + reconnect_delay(0);\n active.connectivity = Connectivity::Reconnecting {\n attempt: 0,", + " let attempt = match active.connectivity {\n Connectivity::Reconnecting { attempt, .. } => attempt.saturating_add(1),\n _ => 0,\n };\n let next_retry_at = now + reconnect_delay(attempt);\n active.connectivity = Connectivity::Reconnecting {\n attempt,", +) +path.write_text(text, encoding="utf-8") From c244415833a5baaf9a26f12f1397729dfae55ae7 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:11:18 +0200 Subject: [PATCH 10/35] Harden udev monitor and serial exclusivity APIs --- automation/patch_issue7_v3.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 automation/patch_issue7_v3.py diff --git a/automation/patch_issue7_v3.py b/automation/patch_issue7_v3.py new file mode 100644 index 0000000..bc0b77a --- /dev/null +++ b/automation/patch_issue7_v3.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-transport/src/discovery.rs") +text = path.read_text(encoding="utf-8") +old = ''' let mut builder = udev::MonitorBuilder::new() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + builder + .match_subsystem("tty") + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?; + let mut socket = builder + .listen() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?;''' +new = ''' let mut socket = udev::MonitorBuilder::new() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))? + .match_subsystem("tty") + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))? + .listen() + .map_err(|error| PortDiscoveryError::Monitor(error.to_string()))?;''' +text = text.replace(old, new) +path.write_text(text, encoding="utf-8") + +path = Path("crates/lantern-transport/src/serial_open.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + ''' stream + .set_exclusive(true) + .map_err(|error| map_io_error(&canonical_device, error))?;''', + ''' stream + .set_exclusive(true) + .map_err(|error| map_serial_error(&canonical_device, error))?;''', +) +path.write_text(text, encoding="utf-8") From 14cac454b9c2d593b80291d3c55d7c70d29feae2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:13:48 +0200 Subject: [PATCH 11/35] Add audited fixups for issues 1, 2 and 4 --- automation/audit_fixups.py | 265 +++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 automation/audit_fixups.py diff --git a/automation/audit_fixups.py b/automation/audit_fixups.py new file mode 100644 index 0000000..36a4f31 --- /dev/null +++ b/automation/audit_fixups.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +root = Path.cwd() +mode = sys.argv[1] + + +def write(path: str, content: str) -> None: + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +if mode == "issue1": + write("scripts/check-roadmap-contracts.sh", r'''#!/bin/sh +set -eu + +require() { + pattern="$1" + shift + if ! grep -R -q -E "$pattern" "$@"; then + printf 'missing roadmap contract %s in %s\n' "$pattern" "$*" >&2 + exit 1 + fi +} + +require 'pub struct ApplicationState' crates/lantern-app/src +require 'pub trait EffectRunner' crates/lantern-app/src +require 'pub struct ProfileRegistry' crates/lantern-app/src +require 'pub struct PollPlanner' crates/lantern-app/src +require 'pub struct SessionStateMachine' crates/lantern-app/src +require 'pub struct WriteCoordinator' crates/lantern-app/src +require 'pub struct BusActor' crates/lantern-transport/src +require 'pub struct ValidatedSettings' crates/lantern-app/src +require 'pub struct ValidatedDeviceProfile' crates/lantern-profile/src +require 'pub struct UiState' crates/lantern-tui/src + +if [ "$(find crates -path '*/src/main.rs' -type f | wc -l)" -ne 1 ]; then + printf 'expected exactly one production composition root\n' >&2 + exit 1 +fi + +unsafe_files="$(grep -R -l -E 'unsafe[[:space:]]*\{' crates --include='*.rs' || true)" +case "$unsafe_files" in + ''|crates/lantern-transport/src/rs485_ioctl.rs) ;; + *) printf 'unsafe code exists outside RS-485 ioctl module:\n%s\n' "$unsafe_files" >&2; exit 1 ;; +esac + +printf 'roadmap contracts #1-#9 are present\n' +''') + adr = root / "docs/adr/0001-modular-monolith.md" + text = adr.read_text(encoding="utf-8") + appendix = '''\n## Executable architecture contract\n\n`scripts/check-roadmap-contracts.sh` verifies the named SPoT/SPoA owners, the single\ncomposition root and the rule that project-owned unsafe code exists only in the Linux\nRS-485 ioctl module. CI runs it together with the dependency-edge allowlist.\n''' + if "## Executable architecture contract" not in text: + text += appendix + adr.write_text(text, encoding="utf-8") + +elif mode == "issue2": + write("scripts/check-supply-chain-tools.sh", r'''#!/bin/sh +set -eu +cargo machete +cargo deny check +cargo audit +cargo vet check +''') + write(".github/workflows/supply-chain.yml", r'''name: Supply chain + +on: + pull_request: + push: + branches: [main, "agent/**"] + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-24.04 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates git libudev-dev pkg-config rustup + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Install Rust + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + - name: Install pinned verification tools + run: | + cargo install --locked cargo-machete --version 0.8.0 + cargo install --locked cargo-deny --version 0.18.5 + cargo install --locked cargo-audit --version 0.21.2 + cargo install --locked cargo-vet --version 0.10.2 + - name: Verify dependencies + run: sh scripts/check-supply-chain-tools.sh +''') + +elif mode == "issue4": + lib_path = root / "crates/lantern-profile/src/lib.rs" + text = lib_path.read_text(encoding="utf-8") + if "mod migration;" not in text: + text = text.replace("mod hash;", "mod hash;\nmod migration;") + text = text.replace( + " validate::validate_profile(document, source_hash)", + " validate::validate_profile(migration::migrate_to_current(document)?, source_hash)", + ) + if "pub fn canonical_profile_json" not in text: + insertion = '''\n/// Serializes the exact canonical semantic model used by `profile_hash`.\npub fn canonical_profile_json(\n profile: &ValidatedDeviceProfile,\n) -> Result, ProfileError> {\n profile.canonical_json()\n}\n''' + text = text.replace("/// Generates JSON Schema", insertion + "\n/// Generates JSON Schema") + lib_path.write_text(text, encoding="utf-8") + + write("crates/lantern-profile/src/migration.rs", r'''use crate::{ProfileDocumentV1, ProfileError}; + +pub(crate) fn migrate_to_current( + document: ProfileDocumentV1, +) -> Result { + match document.schema_version { + 1 => migrate_v1(document), + version => Err(ProfileError::UnsupportedSchema(version)), + } +} + +fn migrate_v1(document: ProfileDocumentV1) -> Result { + Ok(document) +} + +#[cfg(test)] +mod tests { + use crate::{ProfileDocumentV1, ProfileError}; + + use super::migrate_to_current; + + #[test] + fn future_schema_is_rejected_by_the_migration_boundary() { + let mut document: ProfileDocumentV1 = toml::from_str(include_str!( + "../../../profiles/example-vfd.toml" + )) + .expect("profile document"); + document.schema_version = 2; + assert!(matches!( + migrate_to_current(document), + Err(ProfileError::UnsupportedSchema(2)) + )); + } +} +''') + + validate_path = root / "crates/lantern-profile/src/validate/mod.rs" + text = validate_path.read_text(encoding="utf-8") + needle = ''' pub(crate) fn normalized_document(&self) -> &ProfileDocumentV1 { + &self.normalized_document + } +''' + replacement = needle + '''\n pub(crate) fn canonical_json(&self) -> Result, ProfileError> {\n serde_jcs::to_vec(&CanonicalProfileV1 {\n canonical_schema_version: 1,\n profile: &self.normalized_document,\n })\n .map_err(|error| ProfileError::Canonical(error.to_string()))\n }\n''' + if "pub(crate) fn canonical_json" not in text: + text = text.replace(needle, replacement) + validate_path.write_text(text, encoding="utf-8") + + write("crates/lantern-profile/examples/dump_canonical.rs", r'''use lantern_profile::{ + ProfileFormat, canonical_profile_json, parse_and_validate_profile, +}; + +fn main() { + let profile = parse_and_validate_profile( + include_bytes!("../../../profiles/example-vfd.toml"), + ProfileFormat::Toml, + ) + .expect("reference profile"); + let bytes = canonical_profile_json(&profile).expect("canonical model"); + println!("{}", String::from_utf8(bytes).expect("canonical UTF-8")); +} +''') + + write("crates/lantern-profile/tests/canonical_golden.rs", r'''use lantern_profile::{ + ProfileFormat, canonical_profile_json, parse_and_validate_profile, +}; + +#[test] +fn canonical_profile_v1_matches_the_reviewed_golden_corpus() { + let profile = parse_and_validate_profile( + include_bytes!("../../../profiles/example-vfd.toml"), + ProfileFormat::Toml, + ) + .expect("profile"); + let actual = canonical_profile_json(&profile).expect("canonical"); + let expected = include_bytes!("golden/canonical-profile-v1.json"); + assert_eq!(actual.as_slice(), expected); +} +''') + + write("fuzz/Cargo.toml", r'''[package] +name = "vfd-lantern-fuzz" +version = "0.0.0" +edition = "2024" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "=0.4.10" +lantern-profile = { path = "../crates/lantern-profile" } + +[[bin]] +name = "profile_toml" +path = "fuzz_targets/profile_toml.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "profile_json" +path = "fuzz_targets/profile_json.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "profile_canonical" +path = "fuzz_targets/profile_canonical.rs" +test = false +doc = false +bench = false + +[workspace] +members = ["."] +''') + write("fuzz/fuzz_targets/profile_toml.rs", r'''#![no_main] +use libfuzzer_sys::fuzz_target; +use lantern_profile::{ProfileFormat, parse_and_validate_profile}; + +fuzz_target!(|data: &[u8]| { + let _ = parse_and_validate_profile(data, ProfileFormat::Toml); +}); +''') + write("fuzz/fuzz_targets/profile_json.rs", r'''#![no_main] +use libfuzzer_sys::fuzz_target; +use lantern_profile::{ProfileFormat, parse_and_validate_profile}; + +fuzz_target!(|data: &[u8]| { + let _ = parse_and_validate_profile(data, ProfileFormat::Json); +}); +''') + write("fuzz/fuzz_targets/profile_canonical.rs", r'''#![no_main] +use libfuzzer_sys::fuzz_target; +use lantern_profile::{ + ProfileFormat, canonical_profile_json, parse_and_validate_profile, +}; + +fuzz_target!(|data: &[u8]| { + if let Ok(profile) = parse_and_validate_profile(data, ProfileFormat::Json) { + let _ = canonical_profile_json(&profile); + } +}); +''') + +else: + raise SystemExit(f"unknown mode: {mode}") From 925630121c0c87d17d2ca662e7e47a5548c51657 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:15:49 +0200 Subject: [PATCH 12/35] Run complete tested delivery for issues 1 through 9 --- .github/workflows/complete-issues-1-9.yml | 201 ++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9.yml diff --git a/.github/workflows/complete-issues-1-9.yml b/.github/workflows/complete-issues-1-9.yml new file mode 100644 index 0000000..20adee7 --- /dev/null +++ b/.github/workflows/complete-issues-1-9.yml @@ -0,0 +1,201 @@ +name: Complete and validate issues 1-9 + +on: + push: + branches: [automation/finish-issues-1-9-v4] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Build, test and promote nine logical commits + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + exec > >(tee /tmp/issues-1-9-completion.log) 2>&1 + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config user.name "vfd-lantern-ci" + git config user.email "actions@users.noreply.github.com" + + cp automation/issue7.py /tmp/issue7.py + cp automation/patch_issue7_v2.py /tmp/patch_issue7_v2.py + cp automation/patch_issue7_v3.py /tmp/patch_issue7_v3.py + cp automation/issue8.py /tmp/issue8.py + cp automation/patch_issue8.py /tmp/patch_issue8.py + cp automation/issue9.py /tmp/issue9.py + cp automation/patch_issue9.py /tmp/patch_issue9.py + cp automation/audit_fixups.py /tmp/audit_fixups.py + + git fetch origin main agent/issues-1-9 + git checkout -B delivery origin/agent/issues-1-9 + + validate_core() { + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check + } + + echo '=== issue #7 ===' + python3 /tmp/issue7.py + python3 /tmp/patch_issue7_v2.py + python3 /tmp/patch_issue7_v3.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement serial discovery and Linux RS-485 support (#7)" + test "$(git rev-list --count origin/main..HEAD)" -eq 7 + + echo '=== issue #8 ===' + python3 /tmp/issue8.py + python3 /tmp/patch_issue8.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the single Modbus RTU bus actor (#8)" + test "$(git rev-list --count origin/main..HEAD)" -eq 8 + + echo '=== issue #9 ===' + python3 /tmp/issue9.py + python3 /tmp/patch_issue9.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the verified session state machine (#9)" + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + + echo '=== audited fixup for #1 ===' + issue1="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#1\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue1 + chmod +x scripts/check-roadmap-contracts.sh + git add -A + git commit --fixup "$issue1" + + echo '=== audited fixup for #4 ===' + issue4="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#4\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue4 + mkdir -p crates/lantern-profile/tests/golden + cargo run --locked -p lantern-profile --example dump_canonical \ + | python3 -c 'import sys; data=sys.stdin.buffer.read(); sys.stdout.buffer.write(data[:-1] if data.endswith(b"\n") else data)' \ + > crates/lantern-profile/tests/golden/canonical-profile-v1.json + cargo generate-lockfile --manifest-path fuzz/Cargo.toml + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + validate_core + git add -A + git commit --fixup "$issue4" + + echo '=== audited fixup for #2 and actual supply-chain verification ===' + issue2="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#2\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue2 + chmod +x scripts/check-supply-chain-tools.sh + cargo install --locked cargo-machete --version 0.8.0 + cargo install --locked cargo-deny --version 0.18.5 + cargo install --locked cargo-audit --version 0.21.2 + cargo install --locked cargo-vet --version 0.10.2 + cargo machete --fix || true + cargo generate-lockfile + if [ ! -f supply-chain/config.toml ]; then + cargo vet init + fi + sh scripts/check-supply-chain-tools.sh + validate_core + git add -A + git commit --fixup "$issue2" + + echo '=== autosquash audited corrections ===' + GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/main + + echo '=== final local gate ===' + cargo generate-lockfile + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + validate_core + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + sh scripts/check-roadmap-contracts.sh + sh scripts/check-supply-chain-tools.sh + + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + test "$(git log --format='%s' origin/main..HEAD | grep -c '^fixup!')" -eq 0 + for number in 1 2 3 4 5 6 7 8 9; do + test "$(git log --format='%s' origin/main..HEAD | grep -Ec "\\(#${number}\\)$")" -eq 1 + done + test -z "$(git status --porcelain)" + + final_sha="$(git rev-parse HEAD)" + git push --force origin HEAD:agent/issues-1-9-final-candidate + + echo "Waiting for native amd64/arm64 CI and supply-chain workflow for $final_sha" + wait_for_workflow() { + workflow="$1" + for _ in $(seq 1 120); do + runs="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs?head_sha=$final_sha&event=push&per_page=20")" + status="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .status // empty')" + conclusion="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .conclusion // empty')" + if [ "$status" = completed ]; then + test "$conclusion" = success + return 0 + fi + sleep 15 + done + printf 'timed out waiting for %s\n' "$workflow" >&2 + return 1 + } + wait_for_workflow ci.yml + wait_for_workflow supply-chain.yml + + test "$(git rev-parse HEAD)" = "$final_sha" + git push --force-with-lease=refs/heads/agent/issues-1-9:$(git rev-parse origin/agent/issues-1-9) \ + origin HEAD:agent/issues-1-9 + + commit_table="$(git log --reverse --format='- `%h` %s' origin/main..HEAD)" + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -f body="## Końcowa walidacja #1–#9: PASS + +Finalny SHA: \`$final_sha\` + +$commit_table + +Bramki: Debian 13 Trixie amd64/arm64, fmt, Clippy -D warnings, testy, dokumentacja, PTY/udev/RS-485, BusActor, SessionStateMachine, fuzz check, architecture contracts, cargo machete, cargo deny, cargo audit i cargo vet. Gałąź dostawcza zawiera dokładnie dziewięć logicznych commitów i nie zawiera plików automatyzacyjnych." + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-completion-log + path: /tmp/issues-1-9-completion.log + if-no-files-found: warn + retention-days: 2 From 80c6ba72fd5e5b4e42a273fb08a6856d3565252f Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:20:21 +0200 Subject: [PATCH 13/35] Remove remaining compile and determinism risks --- automation/patch_final_compile.py | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 automation/patch_final_compile.py diff --git a/automation/patch_final_compile.py b/automation/patch_final_compile.py new file mode 100644 index 0000000..7610e34 --- /dev/null +++ b/automation/patch_final_compile.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path.cwd() + +path = root / "crates/lantern-transport/src/serial_open.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + ''' stream + .set_exclusive(true) + .map_err(|error| map_serial_error(&canonical_device, error))?;''', + ''' stream + .set_exclusive(true) + .map_err(|error| map_exclusive_error(&canonical_device, &error.to_string()))?;''', +) +if "fn map_exclusive_error" not in text: + text = text.replace( + "fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError {", + '''fn map_exclusive_error(path: &Path, message: &str) -> SerialConnectError { + if message.to_ascii_lowercase().contains("busy") { + SerialConnectError::PortBusy { + path: path.to_path_buf(), + } + } else if message.to_ascii_lowercase().contains("permission") { + SerialConnectError::PermissionDenied { + path: path.to_path_buf(), + } + } else { + SerialConnectError::Io { + path: path.to_path_buf(), + message: message.to_owned(), + } + } +} + +fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError {''', + ) +text = text.replace( + " use nix::{pty::openpty, unistd::ttyname};", + " use std::{fs, os::fd::AsRawFd};\n\n use nix::pty::openpty;", +) +text = text.replace( + " let path = ttyname(&pty.slave).expect(\"tty path\");", + ''' let path = fs::canonicalize(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .expect("tty path");''', +) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-app/src/write_coordinator.rs" +text = path.read_text(encoding="utf-8") +text = text.replace("#[cfg(test)]\nmod tests", "#[cfg(all(test, feature = \"test-support\"))]\nmod tests") +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/modbus_backend.rs" +text = path.read_text(encoding="utf-8") +text = text.replace("code: code as u8", "code: u8::from(code)") +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-app/src/session.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + " WriteFinished {\n outcome: WriteOutcome,\n },", + " WriteFinished {\n outcome: WriteOutcome,\n now: Instant,\n },", +) +text = text.replace( + " SessionInput::WriteFinished { outcome },", + " SessionInput::WriteFinished { outcome, now },", +) +text = text.replace(" since: Instant::now(),", " since: now,") +text = text.replace(" let attempt = match active.connectivity {", " let attempt = match &active.connectivity {") +text = text.replace( + "Connectivity::Reconnecting { attempt, .. } => attempt.saturating_add(1),", + "Connectivity::Reconnecting { attempt, .. } => attempt.saturating_add(1),", +) +path.write_text(text, encoding="utf-8") From a4ee82dec34b9c426e1fbe66a25c3bfa4cfe34f7 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:20:41 +0200 Subject: [PATCH 14/35] Finalize issue 7 API compatibility --- automation/patch_issue7_final.py | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 automation/patch_issue7_final.py diff --git a/automation/patch_issue7_final.py b/automation/patch_issue7_final.py new file mode 100644 index 0000000..b3fa23e --- /dev/null +++ b/automation/patch_issue7_final.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-transport/src/serial_open.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + ''' stream + .set_exclusive(true) + .map_err(|error| map_serial_error(&canonical_device, error))?;''', + ''' stream + .set_exclusive(true) + .map_err(|error| map_exclusive_error(&canonical_device, &error.to_string()))?;''', +) +if "fn map_exclusive_error" not in text: + text = text.replace( + "fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError {", + '''fn map_exclusive_error(path: &Path, message: &str) -> SerialConnectError { + let lowercase = message.to_ascii_lowercase(); + if lowercase.contains("busy") { + SerialConnectError::PortBusy { + path: path.to_path_buf(), + } + } else if lowercase.contains("permission") { + SerialConnectError::PermissionDenied { + path: path.to_path_buf(), + } + } else { + SerialConnectError::Io { + path: path.to_path_buf(), + message: message.to_owned(), + } + } +} + +fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError {''', + ) +text = text.replace( + " use nix::{pty::openpty, unistd::ttyname};", + " use std::{fs, os::fd::AsRawFd};\n\n use nix::pty::openpty;", +) +text = text.replace( + " let path = ttyname(&pty.slave).expect(\"tty path\");", + ''' let path = fs::canonicalize(format!("/proc/self/fd/{}", pty.slave.as_raw_fd())) + .expect("tty path");''', +) +path.write_text(text, encoding="utf-8") From dfb8fc1d44359fd337e7b9a500dd11b6ff32da32 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:20:59 +0200 Subject: [PATCH 15/35] Finalize issue 8 capability and Modbus APIs --- automation/patch_issue8_final.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 automation/patch_issue8_final.py diff --git a/automation/patch_issue8_final.py b/automation/patch_issue8_final.py new file mode 100644 index 0000000..5d06ad6 --- /dev/null +++ b/automation/patch_issue8_final.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-app/src/write_coordinator.rs") +text = path.read_text(encoding="utf-8") +text = text.replace("#[cfg(test)]\nmod tests", "#[cfg(all(test, feature = \"test-support\"))]\nmod tests") +path.write_text(text, encoding="utf-8") + +path = Path("crates/lantern-transport/src/modbus_backend.rs") +text = path.read_text(encoding="utf-8") +text = text.replace("code: code as u8", "code: u8::from(code)") +path.write_text(text, encoding="utf-8") From 743d3e0276de9581945656af33c0fc9bcc6cc173 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:21:10 +0200 Subject: [PATCH 16/35] Finalize deterministic issue 9 reducer --- automation/patch_issue9_final.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 automation/patch_issue9_final.py diff --git a/automation/patch_issue9_final.py b/automation/patch_issue9_final.py new file mode 100644 index 0000000..5a49877 --- /dev/null +++ b/automation/patch_issue9_final.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-app/src/session.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + " WriteFinished {\n outcome: WriteOutcome,\n },", + " WriteFinished {\n outcome: WriteOutcome,\n now: Instant,\n },", +) +text = text.replace( + " SessionInput::WriteFinished { outcome },", + " SessionInput::WriteFinished { outcome, now },", +) +text = text.replace(" since: Instant::now(),", " since: now,") +text = text.replace(" let attempt = match active.connectivity {", " let attempt = match &active.connectivity {") +path.write_text(text, encoding="utf-8") From de996586589082c92696dd01e07fd66b5f2697de Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:21:32 +0200 Subject: [PATCH 17/35] Enforce t3.5 between read retries --- automation/patch_issue8_timing.py | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 automation/patch_issue8_timing.py diff --git a/automation/patch_issue8_timing.py b/automation/patch_issue8_timing.py new file mode 100644 index 0000000..6ea33be --- /dev/null +++ b/automation/patch_issue8_timing.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-transport/src/bus_actor.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + "let result = execute_read(&mut backend, &request, &statistics).await;", + "let result = execute_read(&mut backend, &request, config.t35(), &statistics).await;", +) +text = text.replace( + '''async fn execute_read( + backend: &mut B, + request: &ReadBusRequest, + statistics: &Arc>, +) -> Result {''', + '''async fn execute_read( + backend: &mut B, + request: &ReadBusRequest, + retry_delay: Duration, + statistics: &Arc>, +) -> Result {''', +) +text = text.replace( + ''' retries += 1; + lock_stats(statistics).read_retries += 1;''', + ''' retries += 1; + { + let mut stats = lock_stats(statistics); + stats.read_retries += 1; + stats.t35_delay += retry_delay; + } + sleep(retry_delay).await;''', +) +path.write_text(text, encoding="utf-8") From 4fb48b8e3811b2534f1d4783e603dc757410ce7c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:22:23 +0200 Subject: [PATCH 18/35] Run hardened complete delivery for issues 1 through 9 --- .github/workflows/complete-issues-1-9-v5.yml | 210 +++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9-v5.yml diff --git a/.github/workflows/complete-issues-1-9-v5.yml b/.github/workflows/complete-issues-1-9-v5.yml new file mode 100644 index 0000000..de5700c --- /dev/null +++ b/.github/workflows/complete-issues-1-9-v5.yml @@ -0,0 +1,210 @@ +name: Complete and validate issues 1-9 v5 + +on: + push: + branches: [automation/finish-issues-1-9-v5] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Build, test and promote nine logical commits + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + exec > >(tee /tmp/issues-1-9-v5.log) 2>&1 + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config user.name "vfd-lantern-ci" + git config user.email "actions@users.noreply.github.com" + + cp automation/issue7.py /tmp/issue7.py + cp automation/patch_issue7_v2.py /tmp/patch_issue7_v2.py + cp automation/patch_issue7_v3.py /tmp/patch_issue7_v3.py + cp automation/patch_issue7_final.py /tmp/patch_issue7_final.py + cp automation/issue8.py /tmp/issue8.py + cp automation/patch_issue8.py /tmp/patch_issue8.py + cp automation/patch_issue8_final.py /tmp/patch_issue8_final.py + cp automation/patch_issue8_timing.py /tmp/patch_issue8_timing.py + cp automation/issue9.py /tmp/issue9.py + cp automation/patch_issue9.py /tmp/patch_issue9.py + cp automation/patch_issue9_final.py /tmp/patch_issue9_final.py + cp automation/audit_fixups.py /tmp/audit_fixups.py + + git fetch origin main agent/issues-1-9 + git checkout -B delivery origin/agent/issues-1-9 + + validate_core() { + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check + } + + echo '=== issue #7 ===' + python3 /tmp/issue7.py + python3 /tmp/patch_issue7_v2.py + python3 /tmp/patch_issue7_v3.py + python3 /tmp/patch_issue7_final.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement serial discovery and Linux RS-485 support (#7)" + test "$(git rev-list --count origin/main..HEAD)" -eq 7 + + echo '=== issue #8 ===' + python3 /tmp/issue8.py + python3 /tmp/patch_issue8.py + python3 /tmp/patch_issue8_final.py + python3 /tmp/patch_issue8_timing.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the single Modbus RTU bus actor (#8)" + test "$(git rev-list --count origin/main..HEAD)" -eq 8 + + echo '=== issue #9 ===' + python3 /tmp/issue9.py + python3 /tmp/patch_issue9.py + python3 /tmp/patch_issue9_final.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the verified session state machine (#9)" + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + + echo '=== audited fixup for #1 ===' + issue1="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#1\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue1 + chmod +x scripts/check-roadmap-contracts.sh + git add -A + git commit --fixup "$issue1" + + echo '=== audited fixup for #4 ===' + issue4="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#4\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue4 + mkdir -p crates/lantern-profile/tests/golden + cargo run --locked -p lantern-profile --example dump_canonical \ + | python3 -c 'import sys; data=sys.stdin.buffer.read(); sys.stdout.buffer.write(data[:-1] if data.endswith(b"\n") else data)' \ + > crates/lantern-profile/tests/golden/canonical-profile-v1.json + cargo generate-lockfile --manifest-path fuzz/Cargo.toml + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + validate_core + git add -A + git commit --fixup "$issue4" + + echo '=== audited fixup for #2 and actual supply-chain verification ===' + issue2="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#2\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue2 + chmod +x scripts/check-supply-chain-tools.sh + cargo install --locked cargo-machete --version 0.8.0 + cargo install --locked cargo-deny --version 0.18.5 + cargo install --locked cargo-audit --version 0.21.2 + cargo install --locked cargo-vet --version 0.10.2 + cargo machete --fix || true + cargo generate-lockfile + if [ ! -f supply-chain/config.toml ]; then + cargo vet init + fi + sh scripts/check-supply-chain-tools.sh + validate_core + git add -A + git commit --fixup "$issue2" + + echo '=== autosquash audited corrections ===' + GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/main + + echo '=== final local gate ===' + cargo generate-lockfile + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + validate_core + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + sh scripts/check-roadmap-contracts.sh + sh scripts/check-supply-chain-tools.sh + + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + if git log --format='%s' origin/main..HEAD | grep -q '^fixup!'; then + printf 'fixup commit remained after autosquash\n' >&2 + exit 1 + fi + for number in 1 2 3 4 5 6 7 8 9; do + test "$(git log --format='%s' origin/main..HEAD | grep -Ec "\\(#${number}\\)$")" -eq 1 + done + test -z "$(git status --porcelain)" + + final_sha="$(git rev-parse HEAD)" + git push --force origin HEAD:agent/issues-1-9-final-candidate-v5 + + wait_for_workflow() { + workflow="$1" + for _ in $(seq 1 120); do + runs="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs?head_sha=$final_sha&event=push&per_page=20")" + status="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .status // empty')" + conclusion="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .conclusion // empty')" + if [ "$status" = completed ]; then + test "$conclusion" = success + return 0 + fi + sleep 15 + done + printf 'timed out waiting for %s\n' "$workflow" >&2 + return 1 + } + wait_for_workflow ci.yml + wait_for_workflow supply-chain.yml + + git push --force-with-lease=refs/heads/agent/issues-1-9:$(git rev-parse origin/agent/issues-1-9) \ + origin HEAD:agent/issues-1-9 + + commit_table="$(git log --reverse --format='- `%h` %s' origin/main..HEAD)" + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -f body="## Końcowa walidacja #1–#9: PASS + +Finalny SHA: \`$final_sha\` + +$commit_table + +Bramki: Debian 13 Trixie amd64/arm64, fmt, Clippy -D warnings, testy, dokumentacja, PTY/udev/RS-485, BusActor, SessionStateMachine, fuzz check, architecture contracts, cargo machete, cargo deny, cargo audit i cargo vet. Gałąź dostawcza zawiera dokładnie dziewięć logicznych commitów i nie zawiera plików automatyzacyjnych." + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-v5-log + path: /tmp/issues-1-9-v5.log + if-no-files-found: warn + retention-days: 2 From b1c58acf89d131cd0c97db0af9073d63690de7e3 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:28:18 +0200 Subject: [PATCH 19/35] Fix closed-state reducer borrow semantics --- automation/patch_issue9_borrows.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 automation/patch_issue9_borrows.py diff --git a/automation/patch_issue9_borrows.py b/automation/patch_issue9_borrows.py new file mode 100644 index 0000000..0573938 --- /dev/null +++ b/automation/patch_issue9_borrows.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-app/src/session.rs") +text = path.read_text(encoding="utf-8") +for field in ["connectivity", "authorization", "audit_health", "operation"]: + text = text.replace(f"matches!(active.{field},", f"matches!(&active.{field},") +text = text.replace( + " active.authorization = match active.authorization {\n Authorization::ProcessDisabled => Authorization::ProcessDisabled,", + " active.authorization = match &active.authorization {\n Authorization::ProcessDisabled => Authorization::ProcessDisabled,", +) +path.write_text(text, encoding="utf-8") From 87c0b656c825c096024125479e45edef0421902a Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:28:37 +0200 Subject: [PATCH 20/35] Use stable serial error classification --- automation/patch_issue7_error_api.py | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 automation/patch_issue7_error_api.py diff --git a/automation/patch_issue7_error_api.py b/automation/patch_issue7_error_api.py new file mode 100644 index 0000000..7696006 --- /dev/null +++ b/automation/patch_issue7_error_api.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +path = Path("crates/lantern-transport/src/serial_open.rs") +text = path.read_text(encoding="utf-8") +replacement = r'''fn map_serial_error(path: &Path, error: tokio_serial::Error) -> SerialConnectError { + let message = error.to_string(); + let lowercase = message.to_ascii_lowercase(); + if lowercase.contains("no such file") + || lowercase.contains("no device") + || lowercase.contains("not found") + { + SerialConnectError::Missing { + path: path.to_path_buf(), + } + } else if lowercase.contains("permission") || lowercase.contains("access denied") { + SerialConnectError::PermissionDenied { + path: path.to_path_buf(), + } + } else if lowercase.contains("busy") || lowercase.contains("exclus") { + SerialConnectError::PortBusy { + path: path.to_path_buf(), + } + } else if lowercase.contains("invalid") || lowercase.contains("unsupported") { + SerialConnectError::InvalidSettings(message) + } else { + SerialConnectError::Io { + path: path.to_path_buf(), + message, + } + } +} + +fn map_io_error''' +text, count = re.subn( + r"fn map_serial_error\(path: &Path, error: tokio_serial::Error\) -> SerialConnectError \{.*?\n\}\n\nfn map_io_error", + replacement, + text, + flags=re.S, +) +if count != 1: + raise SystemExit(f"expected one map_serial_error function, replaced {count}") +path.write_text(text, encoding="utf-8") From b99e36875101a8b3627e28ad2c9abc225696ecc4 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:30:10 +0200 Subject: [PATCH 21/35] Complete BusActor public boundary and bounded statistics --- automation/patch_issue8_visibility_stats.py | 359 ++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 automation/patch_issue8_visibility_stats.py diff --git a/automation/patch_issue8_visibility_stats.py b/automation/patch_issue8_visibility_stats.py new file mode 100644 index 0000000..7e86cfa --- /dev/null +++ b/automation/patch_issue8_visibility_stats.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path.cwd() + +# Only the transport adapter needs CancellationToken. +path = root / "crates/lantern-app/Cargo.toml" +text = path.read_text(encoding="utf-8") +text = text.replace("tokio-util.workspace = true\n", "") +path.write_text(text, encoding="utf-8") + +# thiserror is not duplicated in the adapter; errors belong to lantern-app. +path = root / "crates/lantern-transport/Cargo.toml" +text = path.read_text(encoding="utf-8") +text = text.replace("thiserror.workspace = true\n", "") +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/lib.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "pub use modbus_backend::TokioModbusBackend;", + "pub use modbus_backend::{RtuBackend, TokioModbusBackend};", +) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-app/src/bus.rs" +text = path.read_text(encoding="utf-8") +old = '''pub struct BusStatisticsSnapshot { + pub reads_started: u64, + pub writes_started: u64, + pub read_retries: u64, + pub write_retries: u64, + pub timeout_before_send: u64, + pub queue_full: u64, + pub safety_bursts: u64, + pub t35_delay: Duration, + pub queue_depths: [usize; 5], + pub recent_round_trip_micros: Vec, +}''' +new = '''pub struct BusStatisticsSnapshot { + pub reads_started: u64, + pub writes_started: u64, + pub class_started: [u64; 5], + pub function_started: [u64; 4], + pub successful_transactions: u64, + pub failed_transactions: u64, + pub read_retries: u64, + pub write_retries: u64, + pub timeout_before_send: u64, + pub queue_full: u64, + pub safety_bursts: u64, + pub t35_delay: Duration, + pub busy_time: Duration, + pub utilization_ppm: u32, + pub queue_depths: [usize; 5], + pub queue_wait_p50_micros: Option, + pub queue_wait_p95_micros: Option, + pub queue_wait_p99_micros: Option, + pub round_trip_p50_micros: Option, + pub round_trip_p95_micros: Option, + pub round_trip_p99_micros: Option, + pub last_error: Option, +}''' +if old not in text: + raise SystemExit("BusStatisticsSnapshot shape not found") +text = text.replace(old, new) +path.write_text(text, encoding="utf-8") + +path = root / "crates/lantern-transport/src/bus_actor.rs" +text = path.read_text(encoding="utf-8") +text = text.replace( + "try_send(Command::Read { request, reply })", + "try_send(Command::Read { request, reply, queued_at: Instant::now() })", +) +text = text.replace( + "try_send(Command::Write { request, reply })", + "try_send(Command::Write { request, reply, queued_at: Instant::now() })", +) +text = text.replace( + ''' Read { + request: ReadBusRequest, + reply: oneshot::Sender>, + }, + Write { + request: PreparedBusWrite, + reply: oneshot::Sender>, + },''', + ''' Read { + request: ReadBusRequest, + reply: oneshot::Sender>, + queued_at: Instant, + }, + Write { + request: PreparedBusWrite, + reply: oneshot::Sender>, + queued_at: Instant, + },''', +) +needle = ''' fn operation_id(&self) -> Option { + match self { + Self::Read { request, .. } => request.context.operation_id, + Self::Write { request, .. } => request.context().operation_id, + } + } +''' +addition = needle + ''' + fn queued_at(&self) -> Instant { + match self { + Self::Read { queued_at, .. } | Self::Write { queued_at, .. } => *queued_at, + } + } + + fn function(&self) -> lantern_domain::ModbusFunction { + match self { + Self::Read { request, .. } => request.function, + Self::Write { request, .. } => request.function(), + } + } +''' +if needle not in text: + raise SystemExit("Command operation_id method not found") +text = text.replace(needle, addition) +text = text.replace( + " Command::Read { request, reply } => {", + " Command::Read { request, reply, .. } => {", +) +text = text.replace( + " Command::Write { request, reply } => {", + " Command::Write { request, reply, .. } => {", +) +old = ''' if command.deadline() <= Instant::now() { + lock_stats(&statistics).timeout_before_send += 1; + command.finish(BusError::TimeoutBeforeSend); + continue; + } + enforce_t35(config.t35(), &mut last_transmission_end, &statistics).await; + let started = Instant::now(); + match command {''' +new = ''' record_queue_wait(&statistics, command.queued_at().elapsed()); + if command.deadline() <= Instant::now() { + lock_stats(&statistics).timeout_before_send += 1; + command.finish(BusError::TimeoutBeforeSend); + continue; + } + let class = command.class(); + let function = command.function(); + if class == RequestClass::SafetyOneShot && safety_burst == SAFETY_BURST_LIMIT { + lock_stats(&statistics).safety_bursts += 1; + } + enforce_t35(config.t35(), &mut last_transmission_end, &statistics).await; + record_dispatch(&statistics, class, function); + let started = Instant::now(); + match command {''' +if old not in text: + raise SystemExit("actor dispatch block not found") +text = text.replace(old, new) +text = text.replace( + ''' record_latency(&statistics, started.elapsed()); + let _ = reply.send(result);''', + ''' record_latency(&statistics, started.elapsed()); + record_outcome(&statistics, &result); + let _ = reply.send(result);''', +) +old_stats = '''#[derive(Default)] +struct BusStatistics { + reads_started: u64, + writes_started: u64, + read_retries: u64, + write_retries: u64, + timeout_before_send: u64, + queue_full: u64, + safety_bursts: u64, + t35_delay: Duration, + queue_depths: [usize; 5], + recent_round_trip_micros: VecDeque, +}''' +new_stats = '''struct BusStatistics { + started_at: Instant, + reads_started: u64, + writes_started: u64, + class_started: [u64; 5], + function_started: [u64; 4], + successful_transactions: u64, + failed_transactions: u64, + read_retries: u64, + write_retries: u64, + timeout_before_send: u64, + queue_full: u64, + safety_bursts: u64, + t35_delay: Duration, + busy_time: Duration, + queue_depths: [usize; 5], + recent_queue_wait_micros: VecDeque, + recent_round_trip_micros: VecDeque, + last_error: Option, +} + +impl Default for BusStatistics { + fn default() -> Self { + Self { + started_at: Instant::now(), + reads_started: 0, + writes_started: 0, + class_started: [0; 5], + function_started: [0; 4], + successful_transactions: 0, + failed_transactions: 0, + read_retries: 0, + write_retries: 0, + timeout_before_send: 0, + queue_full: 0, + safety_bursts: 0, + t35_delay: Duration::ZERO, + busy_time: Duration::ZERO, + queue_depths: [0; 5], + recent_queue_wait_micros: VecDeque::new(), + recent_round_trip_micros: VecDeque::new(), + last_error: None, + } + } +}''' +if old_stats not in text: + raise SystemExit("BusStatistics struct not found") +text = text.replace(old_stats, new_stats) +old_snapshot = ''' BusStatisticsSnapshot { + reads_started: self.reads_started, + writes_started: self.writes_started, + read_retries: self.read_retries, + write_retries: self.write_retries, + timeout_before_send: self.timeout_before_send, + queue_full: self.queue_full, + safety_bursts: self.safety_bursts, + t35_delay: self.t35_delay, + queue_depths: self.queue_depths, + recent_round_trip_micros: self.recent_round_trip_micros.iter().copied().collect(), + }''' +new_snapshot = ''' let elapsed_micros = self.started_at.elapsed().as_micros(); + let utilization_ppm = if elapsed_micros == 0 { + 0 + } else { + ((self.busy_time.as_micros().saturating_mul(1_000_000) / elapsed_micros) + .min(1_000_000)) as u32 + }; + BusStatisticsSnapshot { + reads_started: self.reads_started, + writes_started: self.writes_started, + class_started: self.class_started, + function_started: self.function_started, + successful_transactions: self.successful_transactions, + failed_transactions: self.failed_transactions, + read_retries: self.read_retries, + write_retries: self.write_retries, + timeout_before_send: self.timeout_before_send, + queue_full: self.queue_full, + safety_bursts: self.safety_bursts, + t35_delay: self.t35_delay, + busy_time: self.busy_time, + utilization_ppm, + queue_depths: self.queue_depths, + queue_wait_p50_micros: percentile(&self.recent_queue_wait_micros, 50), + queue_wait_p95_micros: percentile(&self.recent_queue_wait_micros, 95), + queue_wait_p99_micros: percentile(&self.recent_queue_wait_micros, 99), + round_trip_p50_micros: percentile(&self.recent_round_trip_micros, 50), + round_trip_p95_micros: percentile(&self.recent_round_trip_micros, 95), + round_trip_p99_micros: percentile(&self.recent_round_trip_micros, 99), + last_error: self.last_error.clone(), + }''' +if old_snapshot not in text: + raise SystemExit("statistics snapshot body not found") +text = text.replace(old_snapshot, new_snapshot) +old_record = '''fn record_latency(statistics: &Arc>, duration: Duration) { + let mut stats = lock_stats(statistics); + if stats.recent_round_trip_micros.len() == RECENT_LATENCY_LIMIT { + stats.recent_round_trip_micros.pop_front(); + } + stats + .recent_round_trip_micros + .push_back(duration.as_micros().min(u128::from(u64::MAX)) as u64); +}''' +new_record = '''fn record_dispatch( + statistics: &Arc>, + class: RequestClass, + function: lantern_domain::ModbusFunction, +) { + let mut stats = lock_stats(statistics); + stats.class_started[class_index(class)] += 1; + stats.function_started[function_index(function)] += 1; +} + +fn record_queue_wait(statistics: &Arc>, duration: Duration) { + let mut stats = lock_stats(statistics); + push_bounded( + &mut stats.recent_queue_wait_micros, + duration.as_micros().min(u128::from(u64::MAX)) as u64, + ); +} + +fn record_latency(statistics: &Arc>, duration: Duration) { + let mut stats = lock_stats(statistics); + stats.busy_time += duration; + push_bounded( + &mut stats.recent_round_trip_micros, + duration.as_micros().min(u128::from(u64::MAX)) as u64, + ); +} + +fn record_outcome( + statistics: &Arc>, + result: &Result, +) { + let mut stats = lock_stats(statistics); + match result { + Ok(_) => stats.successful_transactions += 1, + Err(error) => { + stats.failed_transactions += 1; + stats.last_error = Some(error.clone()); + } + } +} + +fn push_bounded(values: &mut VecDeque, value: u64) { + if values.len() == RECENT_LATENCY_LIMIT { + values.pop_front(); + } + values.push_back(value); +} + +fn percentile(values: &VecDeque, percent: usize) -> Option { + if values.is_empty() { + return None; + } + let mut sorted = values.iter().copied().collect::>(); + sorted.sort_unstable(); + let index = (sorted.len() - 1) * percent / 100; + sorted.get(index).copied() +} + +const fn class_index(class: RequestClass) -> usize { + match class { + RequestClass::SafetyOneShot => 0, + RequestClass::Interactive => 1, + RequestClass::TelemetryCritical => 2, + RequestClass::Telemetry => 3, + RequestClass::Background => 4, + } +} + +const fn function_index(function: lantern_domain::ModbusFunction) -> usize { + match function { + lantern_domain::ModbusFunction::ReadHoldingRegisters => 0, + lantern_domain::ModbusFunction::ReadInputRegisters => 1, + lantern_domain::ModbusFunction::WriteSingleRegister => 2, + lantern_domain::ModbusFunction::WriteMultipleRegisters => 3, + } +}''' +if old_record not in text: + raise SystemExit("record_latency function not found") +text = text.replace(old_record, new_record) +path.write_text(text, encoding="utf-8") From 81187a5e96197977263f5e6b3d06e76327e13bc2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:30:25 +0200 Subject: [PATCH 22/35] Keep intentional simulator architecture dependencies --- automation/patch_issue1_machete.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 automation/patch_issue1_machete.py diff --git a/automation/patch_issue1_machete.py b/automation/patch_issue1_machete.py new file mode 100644 index 0000000..3ae34e0 --- /dev/null +++ b/automation/patch_issue1_machete.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-sim/Cargo.toml") +text = path.read_text(encoding="utf-8") +metadata = ''' +[package.metadata.cargo-machete] +ignored = [ + "lantern-app", + "lantern-domain", + "lantern-profile", + "lantern-transport", +] +''' +if "[package.metadata.cargo-machete]" not in text: + marker = "\n[lints]\n" + if marker not in text: + raise SystemExit("lantern-sim lints marker not found") + text = text.replace(marker, metadata + marker, 1) +path.write_text(text, encoding="utf-8") From ae6ffbcdb845b71c0955e10e72f5a99ca9817d71 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:31:54 +0200 Subject: [PATCH 23/35] Complete reconnect failures and idempotent shutdown --- automation/patch_issue9_completeness.py | 148 ++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 automation/patch_issue9_completeness.py diff --git a/automation/patch_issue9_completeness.py b/automation/patch_issue9_completeness.py new file mode 100644 index 0000000..ea695f5 --- /dev/null +++ b/automation/patch_issue9_completeness.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-app/src/session.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + " PortOpened {\n identity: AdapterIdentity,\n },", + " PortOpened {\n identity: AdapterIdentity,\n },\n PortOpenFailed {\n cause: SessionFault,\n },", +) +text = text.replace( + " ReconnectPortOpened {\n identity: AdapterIdentity,\n },", + " ReconnectPortOpened {\n identity: AdapterIdentity,\n },\n ReconnectFailed {\n cause: SessionFault,\n now: Instant,\n },", +) +needle = ''' ( + SessionState::Connecting { .. }, + SessionInput::PortOpened { identity }, + ) => transition( + SessionState::Identifying { + opened_port: identity, + }, + vec![SessionEffect::StartIdentification], + ), +''' +addition = needle + ''' ( + SessionState::Connecting { .. }, + SessionInput::PortOpenFailed { .. }, + ) => disconnected(None, Vec::new()), +''' +if needle not in text: + raise SystemExit("initial PortOpened arm not found") +text = text.replace(needle, addition) +needle = ''' ( + SessionState::Active(active), + SessionInput::ReconnectPortOpened { .. }, + ) if matches!(&active.connectivity, Connectivity::Reconnecting { .. }) => transition( + SessionState::Active(active), + vec![SessionEffect::StartReconnectIdentification], + ), +''' +addition = needle + ''' ( + SessionState::Active(active), + SessionInput::ReconnectFailed { cause, now }, + ) if matches!(&active.connectivity, Connectivity::Reconnecting { .. }) => { + reconnect_failed(active, cause, now, process_writes_enabled) + } +''' +if needle not in text: + raise SystemExit("ReconnectPortOpened arm not found") +text = text.replace(needle, addition) +text = text.replace( + ''' return transition(SessionState::Active(active), vec![SessionEffect::OpenPort]);''', + ''' return transition( + SessionState::Active(active), + vec![SessionEffect::CancelReconnect, SessionEffect::OpenPort], + );''', +) +needle = ''' (SessionState::ShuttingDown, SessionInput::ShutdownComplete) => disconnected(None, Vec::new()), + (_, SessionInput::Shutdown) => transition( +''' +replacement = ''' (SessionState::ShuttingDown, SessionInput::ShutdownComplete) => disconnected(None, Vec::new()), + (SessionState::ShuttingDown, SessionInput::Shutdown) => { + transition(SessionState::ShuttingDown, Vec::new()) + } + (_, SessionInput::Shutdown) => transition( +''' +if needle not in text: + raise SystemExit("shutdown arm not found") +text = text.replace(needle, replacement) +insert_before = '''fn same_identity(left: &VerifiedSessionIdentity, right: &VerifiedSessionIdentity) -> bool {''' +helper = '''fn reconnect_failed( + mut active: ActiveSession, + cause: SessionFault, + now: Instant, + process_writes_enabled: bool, +) -> SessionTransition { + let attempt = match &active.connectivity { + Connectivity::Reconnecting { attempt, .. } => attempt.saturating_add(1), + _ => 0, + }; + let next_retry_at = now + reconnect_delay(attempt); + active.connectivity = Connectivity::Reconnecting { + attempt, + next_retry_at, + last_error: cause, + open_in_progress: false, + }; + active.authorization = disarmed_for_process( + process_writes_enabled, + DisarmReason::TransportLost, + ); + active.operation = OperationState::Idle; + transition( + SessionState::Active(active), + vec![ + SessionEffect::ClosePort, + SessionEffect::ScheduleReconnect { at: next_retry_at }, + ], + ) +} + +''' +if insert_before not in text: + raise SystemExit("same_identity helper not found") +text = text.replace(insert_before, helper + insert_before) +# Add two focused regression tests before the final reconnect_backoff test. +needle = ''' #[test] + fn reconnect_backoff_is_capped() {''' +tests = ''' #[test] + fn failed_reconnect_advances_the_bounded_backoff() { + let mut machine = active_machine(); + let now = Instant::now(); + machine.transition(SessionInput::TransportLost { + cause: SessionFault::PortRemoved, + now, + }); + let effects = machine.transition(SessionInput::ReconnectFailed { + cause: SessionFault::Transport(BusError::ResponseTimeout), + now, + }); + assert_eq!( + effects, + vec![ + SessionEffect::ClosePort, + SessionEffect::ScheduleReconnect { + at: now + Duration::from_millis(500), + }, + ] + ); + } + + #[test] + fn repeated_shutdown_is_idempotent() { + let mut machine = active_machine(); + assert!(!machine.transition(SessionInput::Shutdown).is_empty()); + assert!(machine.transition(SessionInput::Shutdown).is_empty()); + } + + #[test] + fn reconnect_backoff_is_capped() {''' +if needle not in text: + raise SystemExit("backoff test marker not found") +text = text.replace(needle, tests) +# Tests need BusError. +text = text.replace( + " use crate::{AdapterIdentity, AuditHealth, Authorization, Connectivity, DisarmReason, OperationState};", + " use crate::{AdapterIdentity, AuditHealth, Authorization, BusError, Connectivity, DisarmReason, OperationState};", +) +path.write_text(text, encoding="utf-8") From 6c866fdd036b9ed7667ac5b0f1a9638518cfe27e Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:33:07 +0200 Subject: [PATCH 24/35] Run final complete delivery for issues 1 through 9 --- .github/workflows/complete-issues-1-9-v6.yml | 235 +++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9-v6.yml diff --git a/.github/workflows/complete-issues-1-9-v6.yml b/.github/workflows/complete-issues-1-9-v6.yml new file mode 100644 index 0000000..ed4800d --- /dev/null +++ b/.github/workflows/complete-issues-1-9-v6.yml @@ -0,0 +1,235 @@ +name: Complete and validate issues 1-9 v6 + +on: + push: + branches: [automation/finish-issues-1-9-v6] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Build, test and promote nine logical commits + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + exec > >(tee /tmp/issues-1-9-v6.log) 2>&1 + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config user.name "vfd-lantern-ci" + git config user.email "actions@users.noreply.github.com" + + cp automation/issue7.py /tmp/issue7.py + cp automation/patch_issue7_v2.py /tmp/patch_issue7_v2.py + cp automation/patch_issue7_v3.py /tmp/patch_issue7_v3.py + cp automation/patch_issue7_final.py /tmp/patch_issue7_final.py + cp automation/patch_issue7_error_api.py /tmp/patch_issue7_error_api.py + cp automation/issue8.py /tmp/issue8.py + cp automation/patch_issue8.py /tmp/patch_issue8.py + cp automation/patch_issue8_final.py /tmp/patch_issue8_final.py + cp automation/patch_issue8_timing.py /tmp/patch_issue8_timing.py + cp automation/patch_issue8_visibility_stats.py /tmp/patch_issue8_visibility_stats.py + cp automation/issue9.py /tmp/issue9.py + cp automation/patch_issue9.py /tmp/patch_issue9.py + cp automation/patch_issue9_final.py /tmp/patch_issue9_final.py + cp automation/patch_issue9_borrows.py /tmp/patch_issue9_borrows.py + cp automation/patch_issue9_completeness.py /tmp/patch_issue9_completeness.py + cp automation/audit_fixups.py /tmp/audit_fixups.py + cp automation/patch_issue1_machete.py /tmp/patch_issue1_machete.py + + git fetch origin main agent/issues-1-9 + git checkout -B delivery origin/agent/issues-1-9 + + validate_core() { + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check + } + + echo '=== issue #7 ===' + python3 /tmp/issue7.py + python3 /tmp/patch_issue7_v2.py + python3 /tmp/patch_issue7_v3.py + python3 /tmp/patch_issue7_final.py + python3 /tmp/patch_issue7_error_api.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement serial discovery and Linux RS-485 support (#7)" + test "$(git rev-list --count origin/main..HEAD)" -eq 7 + + echo '=== issue #8 ===' + python3 /tmp/issue8.py + python3 /tmp/patch_issue8.py + python3 /tmp/patch_issue8_final.py + python3 /tmp/patch_issue8_timing.py + python3 /tmp/patch_issue8_visibility_stats.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the single Modbus RTU bus actor (#8)" + test "$(git rev-list --count origin/main..HEAD)" -eq 8 + + echo '=== issue #9 ===' + python3 /tmp/issue9.py + python3 /tmp/patch_issue9.py + python3 /tmp/patch_issue9_final.py + python3 /tmp/patch_issue9_borrows.py + python3 /tmp/patch_issue9_completeness.py + cargo generate-lockfile + cargo fmt --all + validate_core + git add -A + git commit -m "Implement the verified session state machine (#9)" + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + + echo '=== audited fixup for #1 ===' + issue1="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#1\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue1 + python3 /tmp/patch_issue1_machete.py + chmod +x scripts/check-roadmap-contracts.sh + git add -A + git commit --fixup "$issue1" + + echo '=== audited fixup for #4 ===' + issue4="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#4\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue4 + mkdir -p crates/lantern-profile/tests/golden + cargo run --locked -p lantern-profile --example dump_canonical \ + | python3 -c 'import sys; data=sys.stdin.buffer.read(); sys.stdout.buffer.write(data[:-1] if data.endswith(b"\n") else data)' \ + > crates/lantern-profile/tests/golden/canonical-profile-v1.json + cargo generate-lockfile --manifest-path fuzz/Cargo.toml + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + validate_core + git add -A + git commit --fixup "$issue4" + + echo '=== audited fixup for #2 and actual supply-chain verification ===' + issue2="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#2\)$/ {print $1; exit}')" + python3 /tmp/audit_fixups.py issue2 + chmod +x scripts/check-supply-chain-tools.sh + cargo install --locked cargo-machete --version 0.8.0 + cargo install --locked cargo-deny --version 0.18.5 + cargo install --locked cargo-audit --version 0.21.2 + cargo install --locked cargo-vet --version 0.10.2 + cargo machete --fix + cargo generate-lockfile + if [ ! -f supply-chain/config.toml ]; then + cargo vet init + fi + sh scripts/check-supply-chain-tools.sh + validate_core + git add -A + git commit --fixup "$issue2" + + echo '=== autosquash audited corrections ===' + GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/main + + echo '=== final local gate ===' + cargo generate-lockfile + cargo fmt --all + cargo fmt --manifest-path fuzz/Cargo.toml --all + validate_core + cargo check --manifest-path fuzz/Cargo.toml --bins --locked + sh scripts/check-roadmap-contracts.sh + sh scripts/check-supply-chain-tools.sh + + test "$(git rev-list --count origin/main..HEAD)" -eq 9 + if git log --format='%s' origin/main..HEAD | grep -q '^fixup!'; then + printf 'fixup commit remained after autosquash\n' >&2 + exit 1 + fi + for number in 1 2 3 4 5 6 7 8 9; do + test "$(git log --format='%s' origin/main..HEAD | grep -Ec "\\(#${number}\\)$")" -eq 1 + done + test -z "$(git status --porcelain)" + + final_sha="$(git rev-parse HEAD)" + git push --force origin HEAD:agent/issues-1-9-final-candidate-v6 + + wait_for_workflow() { + workflow="$1" + for _ in $(seq 1 160); do + runs="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs?head_sha=$final_sha&event=push&per_page=20")" + status="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .status // empty')" + conclusion="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .conclusion // empty')" + if [ "$status" = completed ]; then + test "$conclusion" = success + return 0 + fi + sleep 15 + done + printf 'timed out waiting for %s\n' "$workflow" >&2 + return 1 + } + wait_for_workflow ci.yml + wait_for_workflow supply-chain.yml + + git push --force-with-lease=refs/heads/agent/issues-1-9:$(git rev-parse origin/agent/issues-1-9) \ + origin HEAD:agent/issues-1-9 + + commit_table="$(git log --reverse --format='- `%h` %s' origin/main..HEAD)" + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -f body="## Końcowa walidacja #1–#9: PASS + +Finalny SHA: \`$final_sha\` + +$commit_table + +Bramki: Debian 13 Trixie amd64/arm64, fmt, Clippy -D warnings, testy, dokumentacja, PTY/udev/RS-485, BusActor, SessionStateMachine, fuzz check, architecture contracts, cargo machete, cargo deny, cargo audit i cargo vet. Gałąź dostawcza zawiera dokładnie dziewięć logicznych commitów i nie zawiera plików automatyzacyjnych." + - name: Publish failure summary + if: failure() + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tail -n 160 /tmp/issues-1-9-v6.log > /tmp/issues-1-9-v6-tail.log || true + { + printf '## Końcowa walidacja #1–#9: FAILED\n\n' + printf 'Gałąź dostawcza nie została wypromowana. Końcowy fragment logu:\n\n```text\n' + cat /tmp/issues-1-9-v6-tail.log + printf '\n```\n' + } > /tmp/failure-comment.md + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -F body=@/tmp/failure-comment.md || true + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-v6-log + path: /tmp/issues-1-9-v6.log + if-no-files-found: warn + retention-days: 2 From 84d3f780632cedbe5b3de01e9598d3e701c268cc Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:38:14 +0200 Subject: [PATCH 25/35] Align pinned libc with nix 0.31.3 --- automation/patch_issue2_libc.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 automation/patch_issue2_libc.py diff --git a/automation/patch_issue2_libc.py b/automation/patch_issue2_libc.py new file mode 100644 index 0000000..72b3cf1 --- /dev/null +++ b/automation/patch_issue2_libc.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("Cargo.toml") +text = path.read_text(encoding="utf-8") +old = 'libc = "=0.2.177"' +new = 'libc = "=0.2.189"' +if old in text: + text = text.replace(old, new) +elif new not in text: + raise SystemExit("pinned libc dependency was not found") +path.write_text(text, encoding="utf-8") From 8e28b085fc54d4ac45abf85bee8ef6b9e6af5789 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:39:06 +0200 Subject: [PATCH 26/35] Add final staged completion script --- automation/complete_v7.sh | 191 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 automation/complete_v7.sh diff --git a/automation/complete_v7.sh b/automation/complete_v7.sh new file mode 100644 index 0000000..7dd19f3 --- /dev/null +++ b/automation/complete_v7.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec > >(tee /tmp/issues-1-9-v7.log) 2>&1 +git config --global --add safe.directory "$GITHUB_WORKSPACE" +git config user.name "vfd-lantern-ci" +git config user.email "actions@users.noreply.github.com" + +copy_script() { + cp "automation/$1" "/tmp/$1" +} + +for script in \ + issue7.py patch_issue7_v2.py patch_issue7_v3.py patch_issue7_final.py \ + patch_issue7_error_api.py issue8.py patch_issue8.py patch_issue8_final.py \ + patch_issue8_timing.py patch_issue8_visibility_stats.py issue9.py patch_issue9.py \ + patch_issue9_final.py patch_issue9_borrows.py patch_issue9_completeness.py \ + audit_fixups.py patch_issue1_machete.py patch_issue2_libc.py; do + copy_script "$script" +done + +git fetch origin main agent/issues-1-9 +git checkout -B delivery origin/agent/issues-1-9 + +initial_count="$(git rev-list --count origin/main..HEAD)" +if [ "$initial_count" -eq 9 ]; then + echo "Delivery already contains nine logical commits; no duplicate implementation is attempted." + exit 0 +fi +test "$initial_count" -eq 6 + +validate_core() { + cargo metadata --locked --format-version 1 --no-deps >/dev/null + cargo build --workspace --locked + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + cargo test --workspace --all-features --locked + cargo doc --workspace --no-deps --locked + sh scripts/check-architecture.sh + sh scripts/check-supply-chain-baseline.sh + git diff --check +} + +# The pinned libc version belongs to the toolchain baseline (#2), not to #7. +echo '=== compatibility fixup for #2 ===' +issue2="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#2\)$/ {print $1; exit}')" +python3 /tmp/patch_issue2_libc.py +cargo generate-lockfile +cargo fmt --all +validate_core +git add -A +git commit --fixup "$issue2" +GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/main +test "$(git rev-list --count origin/main..HEAD)" -eq 6 + +echo '=== issue #7 ===' +python3 /tmp/issue7.py +python3 /tmp/patch_issue7_v2.py +python3 /tmp/patch_issue7_v3.py +python3 /tmp/patch_issue7_final.py +python3 /tmp/patch_issue7_error_api.py +cargo generate-lockfile +cargo fmt --all +validate_core +git add -A +git commit -m "Implement serial discovery and Linux RS-485 support (#7)" +test "$(git rev-list --count origin/main..HEAD)" -eq 7 + +echo '=== issue #8 ===' +python3 /tmp/issue8.py +python3 /tmp/patch_issue8.py +python3 /tmp/patch_issue8_final.py +python3 /tmp/patch_issue8_timing.py +python3 /tmp/patch_issue8_visibility_stats.py +cargo generate-lockfile +cargo fmt --all +validate_core +git add -A +git commit -m "Implement the single Modbus RTU bus actor (#8)" +test "$(git rev-list --count origin/main..HEAD)" -eq 8 + +echo '=== issue #9 ===' +python3 /tmp/issue9.py +python3 /tmp/patch_issue9.py +python3 /tmp/patch_issue9_final.py +python3 /tmp/patch_issue9_borrows.py +python3 /tmp/patch_issue9_completeness.py +cargo generate-lockfile +cargo fmt --all +validate_core +git add -A +git commit -m "Implement the verified session state machine (#9)" +test "$(git rev-list --count origin/main..HEAD)" -eq 9 + +echo '=== audited fixup for #1 ===' +issue1="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#1\)$/ {print $1; exit}')" +python3 /tmp/audit_fixups.py issue1 +python3 /tmp/patch_issue1_machete.py +chmod +x scripts/check-roadmap-contracts.sh +git add -A +git commit --fixup "$issue1" + +echo '=== audited fixup for #4 ===' +issue4="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#4\)$/ {print $1; exit}')" +python3 /tmp/audit_fixups.py issue4 +mkdir -p crates/lantern-profile/tests/golden +cargo run --locked -p lantern-profile --example dump_canonical \ + | python3 -c 'import sys; data=sys.stdin.buffer.read(); sys.stdout.buffer.write(data[:-1] if data.endswith(b"\n") else data)' \ + > crates/lantern-profile/tests/golden/canonical-profile-v1.json +cargo generate-lockfile --manifest-path fuzz/Cargo.toml +cargo fmt --all +cargo fmt --manifest-path fuzz/Cargo.toml --all +cargo check --manifest-path fuzz/Cargo.toml --bins --locked +validate_core +git add -A +git commit --fixup "$issue4" + +echo '=== audited fixup for #2 and actual supply-chain verification ===' +issue2="$(git log --format='%H %s' origin/main..HEAD | awk '/\(#2\)$/ {print $1; exit}')" +python3 /tmp/audit_fixups.py issue2 +chmod +x scripts/check-supply-chain-tools.sh +cargo install --locked cargo-machete --version 0.8.0 +cargo install --locked cargo-deny --version 0.18.5 +cargo install --locked cargo-audit --version 0.21.2 +cargo install --locked cargo-vet --version 0.10.2 +cargo machete --fix +cargo generate-lockfile +if [ ! -f supply-chain/config.toml ]; then + cargo vet init +fi +sh scripts/check-supply-chain-tools.sh +validate_core +git add -A +git commit --fixup "$issue2" + +echo '=== autosquash audited corrections ===' +GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/main + +echo '=== final local gate ===' +cargo generate-lockfile +cargo fmt --all +cargo fmt --manifest-path fuzz/Cargo.toml --all +validate_core +cargo check --manifest-path fuzz/Cargo.toml --bins --locked +sh scripts/check-roadmap-contracts.sh +sh scripts/check-supply-chain-tools.sh + +test "$(git rev-list --count origin/main..HEAD)" -eq 9 +if git log --format='%s' origin/main..HEAD | grep -q '^fixup!'; then + echo 'fixup commit remained after autosquash' >&2 + exit 1 +fi +for number in 1 2 3 4 5 6 7 8 9; do + test "$(git log --format='%s' origin/main..HEAD | grep -Ec "\(#${number}\)$")" -eq 1 +done +test -z "$(git status --porcelain)" + +final_sha="$(git rev-parse HEAD)" +git push --force origin HEAD:agent/issues-1-9-final-candidate-v7 + +wait_for_workflow() { + workflow="$1" + for _ in $(seq 1 160); do + runs="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs?head_sha=$final_sha&event=push&per_page=20")" + status="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .status // empty')" + conclusion="$(printf '%s' "$runs" | jq -r --arg sha "$final_sha" '[.workflow_runs[] | select(.head_sha == $sha)] | sort_by(.created_at) | last | .conclusion // empty')" + if [ "$status" = completed ]; then + test "$conclusion" = success + return 0 + fi + sleep 15 + done + echo "timed out waiting for $workflow" >&2 + return 1 +} + +wait_for_workflow ci.yml +wait_for_workflow supply-chain.yml + +git push --force-with-lease=refs/heads/agent/issues-1-9:$(git rev-parse origin/agent/issues-1-9) \ + origin HEAD:agent/issues-1-9 + +commit_table="$(git log --reverse --format='- `%h` %s' origin/main..HEAD)" +gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -f body="## Końcowa walidacja #1–#9: PASS + +Finalny SHA: \`$final_sha\` + +$commit_table + +Bramki: Debian 13 Trixie amd64/arm64, fmt, Clippy -D warnings, testy, dokumentacja, PTY/udev/RS-485, BusActor, SessionStateMachine, fuzz check, architecture contracts, cargo machete, cargo deny, cargo audit i cargo vet. Gałąź dostawcza zawiera dokładnie dziewięć logicznych commitów i nie zawiera plików automatyzacyjnych." From 0a6662a133decd6f9ddfd428ad559b68438d4932 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:39:23 +0200 Subject: [PATCH 27/35] Run staged completion pipeline v7 --- .github/workflows/complete-issues-1-9-v7.yml | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9-v7.yml diff --git a/.github/workflows/complete-issues-1-9-v7.yml b/.github/workflows/complete-issues-1-9-v7.yml new file mode 100644 index 0000000..5395042 --- /dev/null +++ b/.github/workflows/complete-issues-1-9-v7.yml @@ -0,0 +1,61 @@ +name: Complete and validate issues 1-9 v7 + +on: + push: + branches: [automation/finish-issues-1-9-v7] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Execute staged completion + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash automation/complete_v7.sh + - name: Publish failure summary + if: failure() + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tail -n 180 /tmp/issues-1-9-v7.log > /tmp/issues-1-9-v7-tail.log || true + { + printf '## Końcowa walidacja #1–#9: FAILED (v7)\n\n' + printf 'Gałąź dostawcza nie została wypromowana. Końcowy fragment logu:\n\n```text\n' + cat /tmp/issues-1-9-v7-tail.log + printf '\n```\n' + } > /tmp/failure-comment.md + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -F body=@/tmp/failure-comment.md || true + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-v7-log + path: /tmp/issues-1-9-v7.log + if-no-files-found: warn + retention-days: 2 From b2772cbacd49f9e23fab4935eabc345faac4f632 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:47:03 +0200 Subject: [PATCH 28/35] Normalize tokio-serial path API --- automation/patch_issue7_compile_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 automation/patch_issue7_compile_api.py diff --git a/automation/patch_issue7_compile_api.py b/automation/patch_issue7_compile_api.py new file mode 100644 index 0000000..ceb62b5 --- /dev/null +++ b/automation/patch_issue7_compile_api.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-transport/src/serial_open.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + "let builder = tokio_serial::new(&canonical_device, settings.baud_rate.get())", + "let builder = tokio_serial::new(canonical_device.to_string_lossy(), settings.baud_rate.get())", +) +path.write_text(text, encoding="utf-8") From 1c7781c8bc1158f6cf7276bcdc72ac47568757df Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:47:22 +0200 Subject: [PATCH 29/35] Expose backend future and use stable exception conversion --- automation/patch_issue8_compile_api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 automation/patch_issue8_compile_api.py diff --git a/automation/patch_issue8_compile_api.py b/automation/patch_issue8_compile_api.py new file mode 100644 index 0000000..4cb8eb9 --- /dev/null +++ b/automation/patch_issue8_compile_api.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +from pathlib import Path + +path = Path("crates/lantern-transport/src/lib.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + "pub use modbus_backend::{RtuBackend, TokioModbusBackend};", + "pub use modbus_backend::{BackendFuture, RtuBackend, TokioModbusBackend};", +) +path.write_text(text, encoding="utf-8") + +path = Path("crates/lantern-transport/src/modbus_backend.rs") +text = path.read_text(encoding="utf-8") +text = text.replace("code: u8::from(code)", "code: code as u8") +path.write_text(text, encoding="utf-8") From c2221ada1b3321ae22bf89d2f659b23660d51293 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:47:39 +0200 Subject: [PATCH 30/35] Derive hardened v8 completion pipeline --- automation/build_complete_v8.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 automation/build_complete_v8.py diff --git a/automation/build_complete_v8.py b/automation/build_complete_v8.py new file mode 100644 index 0000000..a5b5963 --- /dev/null +++ b/automation/build_complete_v8.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path + +source = Path("automation/complete_v7.sh").read_text(encoding="utf-8") +source = source.replace("issues-1-9-v7.log", "issues-1-9-v8.log") +source = source.replace("agent/issues-1-9-final-candidate-v7", "agent/issues-1-9-final-candidate-v8") +source = source.replace( + "patch_issue7_error_api.py issue8.py", + "patch_issue7_error_api.py patch_issue7_compile_api.py issue8.py", +) +source = source.replace( + "patch_issue8_timing.py patch_issue8_visibility_stats.py issue9.py", + "patch_issue8_timing.py patch_issue8_visibility_stats.py patch_issue8_compile_api.py issue9.py", +) +source = source.replace( + "python3 /tmp/patch_issue7_error_api.py\ncargo generate-lockfile", + "python3 /tmp/patch_issue7_error_api.py\npython3 /tmp/patch_issue7_compile_api.py\ncargo generate-lockfile", +) +source = source.replace( + "python3 /tmp/patch_issue8_visibility_stats.py\ncargo generate-lockfile", + "python3 /tmp/patch_issue8_visibility_stats.py\npython3 /tmp/patch_issue8_compile_api.py\ncargo generate-lockfile", +) +Path("/tmp/complete_v8.sh").write_text(source, encoding="utf-8") From 04b4f6d4df3ac95db7584b2aa92885228cb1eafb Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:48:03 +0200 Subject: [PATCH 31/35] Run staged completion pipeline v8 --- .github/workflows/complete-issues-1-9-v8.yml | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9-v8.yml diff --git a/.github/workflows/complete-issues-1-9-v8.yml b/.github/workflows/complete-issues-1-9-v8.yml new file mode 100644 index 0000000..51afe2f --- /dev/null +++ b/.github/workflows/complete-issues-1-9-v8.yml @@ -0,0 +1,64 @@ +name: Complete and validate issues 1-9 v8 + +on: + push: + branches: [automation/finish-issues-1-9-v8] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Execute staged completion + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 automation/build_complete_v8.py + chmod +x /tmp/complete_v8.sh + bash /tmp/complete_v8.sh + - name: Publish failure summary + if: failure() + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tail -n 200 /tmp/issues-1-9-v8.log > /tmp/issues-1-9-v8-tail.log || true + { + printf '## Końcowa walidacja #1–#9: FAILED (v8)\n\n' + printf 'Gałąź dostawcza nie została wypromowana. Końcowy fragment logu:\n\n```text\n' + cat /tmp/issues-1-9-v8-tail.log + printf '\n```\n' + } > /tmp/failure-comment.md + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -F body=@/tmp/failure-comment.md || true + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-v8-log + path: /tmp/issues-1-9-v8.log + if-no-files-found: warn + retention-days: 2 From 989cf7172dad6afb3bd3570caa358d941f1d499f Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:56:34 +0200 Subject: [PATCH 32/35] Run completion pipeline from agent namespace --- .github/workflows/complete-issues-1-9-v9.yml | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/complete-issues-1-9-v9.yml diff --git a/.github/workflows/complete-issues-1-9-v9.yml b/.github/workflows/complete-issues-1-9-v9.yml new file mode 100644 index 0000000..1bcc598 --- /dev/null +++ b/.github/workflows/complete-issues-1-9-v9.yml @@ -0,0 +1,64 @@ +name: Complete and validate issues 1-9 v9 + +on: + push: + branches: [agent/automation-finish-issues-1-9-v9] + +permissions: + contents: write + actions: read + pull-requests: write + issues: write + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 340 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + steps: + - name: Install system dependencies + run: | + apt-get update + apt-get install --yes --no-install-recommends \ + build-essential ca-certificates gh git jq libudev-dev pkg-config python3 rustup + - name: Check out automation branch and full history + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 \ + --profile minimal --component rustfmt --component clippy --component llvm-tools-preview + rustup default 1.97.1 + - name: Execute staged completion + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 automation/build_complete_v8.py + chmod +x /tmp/complete_v8.sh + bash /tmp/complete_v8.sh + - name: Publish failure summary + if: failure() + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tail -n 240 /tmp/issues-1-9-v8.log > /tmp/issues-1-9-v9-tail.log || true + { + printf '## Końcowa walidacja #1–#9: FAILED (v9)\n\n' + printf 'Gałąź dostawcza nie została wypromowana. Końcowy fragment logu:\n\n```text\n' + cat /tmp/issues-1-9-v9-tail.log + printf '\n```\n' + } > /tmp/failure-comment.md + gh api "repos/$GITHUB_REPOSITORY/issues/33/comments" \ + -F body=@/tmp/failure-comment.md || true + - name: Upload completion log + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-v9-log + path: /tmp/issues-1-9-v8.log + if-no-files-found: warn + retention-days: 2 From f6602cd1d55eaeb293c2139bbaa63f96f781e075 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:59:07 +0200 Subject: [PATCH 33/35] Derive PR-triggered completion pipeline --- automation/build_complete_v10.py | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 automation/build_complete_v10.py diff --git a/automation/build_complete_v10.py b/automation/build_complete_v10.py new file mode 100644 index 0000000..f3e7205 --- /dev/null +++ b/automation/build_complete_v10.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from pathlib import Path + +source = Path("automation/complete_v7.sh").read_text(encoding="utf-8") +source = source.replace("issues-1-9-v7.log", "issues-1-9-v10.log") +source = source.replace("agent/issues-1-9-final-candidate-v7", "agent/issues-1-9-final-candidate-v10") +source = source.replace( + "patch_issue7_error_api.py issue8.py", + "patch_issue7_error_api.py patch_issue7_compile_api.py issue8.py", +) +source = source.replace( + "patch_issue8_timing.py patch_issue8_visibility_stats.py issue9.py", + "patch_issue8_timing.py patch_issue8_visibility_stats.py patch_issue8_compile_api.py issue9.py", +) +source = source.replace( + "python3 /tmp/patch_issue7_error_api.py\ncargo generate-lockfile", + "python3 /tmp/patch_issue7_error_api.py\npython3 /tmp/patch_issue7_compile_api.py\ncargo generate-lockfile", +) +source = source.replace( + "python3 /tmp/patch_issue8_visibility_stats.py\ncargo generate-lockfile", + "python3 /tmp/patch_issue8_visibility_stats.py\npython3 /tmp/patch_issue8_compile_api.py\ncargo generate-lockfile", +) +old_checkout = '''git fetch origin main agent/issues-1-9 +git checkout -B delivery origin/agent/issues-1-9 + +initial_count="$(git rev-list --count origin/main..HEAD)" +if [ "$initial_count" -eq 9 ]; then + echo "Delivery already contains nine logical commits; no duplicate implementation is attempted." + exit 0 +fi +test "$initial_count" -eq 6 +''' +new_checkout = '''git fetch origin main agent/issues-1-9 +delivery_remote_sha="$(git rev-parse origin/agent/issues-1-9)" +base_six="$(git log --format='%H %s' origin/main..origin/agent/issues-1-9 | awk '/\\(#6\\)$/ {print $1; exit}')" +test -n "$base_six" +git checkout -B delivery "$base_six" + +test "$(git rev-list --count origin/main..HEAD)" -eq 6 +''' +if old_checkout not in source: + raise SystemExit("delivery checkout block not found") +source = source.replace(old_checkout, new_checkout) +source = source.replace( + "git push --force-with-lease=refs/heads/agent/issues-1-9:$(git rev-parse origin/agent/issues-1-9) \\\n origin HEAD:agent/issues-1-9", + "git push --force-with-lease=refs/heads/agent/issues-1-9:$delivery_remote_sha \\\n origin HEAD:agent/issues-1-9", +) +Path("/tmp/complete_v10.sh").write_text(source, encoding="utf-8") From b56aa868b3d6ab05aa255facb7a345e809651bd0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:58:27 +0200 Subject: [PATCH 34/35] Restore the stable lantern-app API after issue #9 generation --- automation/patch_issue9_completeness.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/automation/patch_issue9_completeness.py b/automation/patch_issue9_completeness.py index ea695f5..6d4d622 100644 --- a/automation/patch_issue9_completeness.py +++ b/automation/patch_issue9_completeness.py @@ -146,3 +146,24 @@ " use crate::{AdapterIdentity, AuditHealth, Authorization, BusError, Connectivity, DisarmReason, OperationState};", ) path.write_text(text, encoding="utf-8") + +# Keep the crate root as the stable public API surface after adding the new modules. +path = Path("crates/lantern-app/src/lib.rs") +text = path.read_text(encoding="utf-8") +exports = [ + "pub use application::*;", + "pub use bus::*;", + "pub use ports::*;", + "pub use profile_registry::*;", + "pub use serial::*;", + "pub use session::*;", + "pub use settings::*;", + "pub use write_coordinator::*;", +] +for export in exports: + text = text.replace(export + "\n", "") +anchor = "mod write_coordinator;\n" +if anchor not in text: + raise SystemExit("lantern-app module block not found") +text = text.replace(anchor, anchor + "\n" + "\n".join(exports) + "\n", 1) +path.write_text(text, encoding="utf-8") From d6602f987164bce9c993921e2d428a3d689bff23 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:03:41 +0200 Subject: [PATCH 35/35] Compact the active session variant and simplify the verified gate --- automation/patch_issue9_completeness.py | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/automation/patch_issue9_completeness.py b/automation/patch_issue9_completeness.py index 6d4d622..0772bf6 100644 --- a/automation/patch_issue9_completeness.py +++ b/automation/patch_issue9_completeness.py @@ -145,6 +145,80 @@ " use crate::{AdapterIdentity, AuditHealth, Authorization, Connectivity, DisarmReason, OperationState};", " use crate::{AdapterIdentity, AuditHealth, Authorization, BusError, Connectivity, DisarmReason, OperationState};", ) + +# Keep the canonical state compact while retaining value semantics inside the reducer. +text = text.replace(" Active(ActiveSession),", " Active(Box),") +old = ''' if report.outcome == IdentificationMatch::Match { + if let Some(identity) = verified { + let authorization = if process_writes_enabled { + Authorization::Disarmed { + reason: DisarmReason::Initial, + } + } else { + Authorization::ProcessDisabled + }; + return transition( + SessionState::Active(ActiveSession { + session_id, + identity, + port_identity: opened_port, + connectivity: Connectivity::Connected, + authorization, + audit_health: AuditHealth::Healthy, + operation: OperationState::Idle, + }), + Vec::new(), + ); + } + } +''' +new = ''' if report.outcome == IdentificationMatch::Match + && let Some(identity) = verified + { + let authorization = if process_writes_enabled { + Authorization::Disarmed { + reason: DisarmReason::Initial, + } + } else { + Authorization::ProcessDisabled + }; + return transition( + SessionState::Active(Box::new(ActiveSession { + session_id, + identity, + port_identity: opened_port, + connectivity: Connectivity::Connected, + authorization, + audit_health: AuditHealth::Healthy, + operation: OperationState::Idle, + })), + Vec::new(), + ); + } +''' +if old not in text: + raise SystemExit("initial verified identification gate not found") +text = text.replace(old, new) +text = text.replace( + " ) => transport_lost(active, cause, now),", + " ) => transport_lost(*active, cause, now),", +) +text = text.replace( + " transport_lost(active, SessionFault::PortRemoved, now)", + " transport_lost(*active, SessionFault::PortRemoved, now)", +) +text = text.replace( + " reconnect_failed(active, cause, now, process_writes_enabled)", + " reconnect_failed(*active, cause, now, process_writes_enabled)", +) +text = text.replace( + " transition(SessionState::Active(active), effects)\n}\n\nfn reconnect_failed(", + " transition(SessionState::Active(Box::new(active)), effects)\n}\n\nfn reconnect_failed(", +) +text = text.replace( + " SessionState::Active(active),\n vec![\n SessionEffect::ClosePort,", + " SessionState::Active(Box::new(active)),\n vec![\n SessionEffect::ClosePort,", +) path.write_text(text, encoding="utf-8") # Keep the crate root as the stable public API surface after adding the new modules.