From 8413b382b178991d2e1e597f1422d147f3454581 Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 29 Oct 2026 22:41:38 +0200 Subject: [PATCH] Add explicit vocabulary and named operation contexts --- PLANS.md | 26 +++--- docs/evidence/milestone-20.md | 80 ++++++++++++++++++ src/app.rs | 96 ++++++++++----------- src/container_driver.rs | 2 +- src/domain.rs | 68 +++++++-------- src/persistence.rs | 14 +--- src/tui/events.rs | 151 ++++++++++++++++++++++++---------- src/tui/render.rs | 40 ++++----- src/tui/state.rs | 36 +++----- src/tui/terminal.rs | 15 ++-- tests/client_delivery.rs | 8 +- tests/commit_restore.rs | 16 ++-- tests/runtime_restart.rs | 10 +-- tests/tui_workflow.rs | 23 +++--- 14 files changed, 344 insertions(+), 241 deletions(-) create mode 100644 docs/evidence/milestone-20.md diff --git a/PLANS.md b/PLANS.md index f003965..2c22999 100644 --- a/PLANS.md +++ b/PLANS.md @@ -129,7 +129,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted | | 18 | Readable, complete in-app help for implemented workflows | 17 | accepted | | 19 | Refactoring contract, terminology and behavior map | 18 | accepted | -| 20 | Explicit vocabulary and named operation contexts | 19 | pending | +| 20 | Explicit vocabulary and named operation contexts | 19 | accepted | | 21 | Domain ownership of reservations and apply knowledge | 20 | pending | | 22 | Transactional draft editing and client creation vertical slice | 21 | pending | | 23 | Readable saved-state Commit and restore scenarios | 22 | pending | @@ -3877,7 +3877,7 @@ Accepted for presentation and synthetic fixture scope after independent review, ## Approved domain refactoring programme — milestones 19–27 -Status: M19 accepted; M20–M27 are pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work. +Status: M19–M20 accepted; M21–M27 pending. The user approved the proposed direction and requested detailed milestones on 2026-10-29. This paragraph records that plan's origin; current task progress is in the milestone records and resume point below. M18 remains accepted. These milestones preserve the approved product contract and all existing acceptance limitations, including retired/unaccepted M9 and unverified real-system paths. They do not resume VM work. ### Shared execution contract for M19–M27 @@ -3928,7 +3928,7 @@ Evidence/handoff: [M19 evidence](docs/evidence/milestone-19.md) identifies the d ### Milestone 20 — Explicit vocabulary and named operation contexts -Status: pending. Prerequisite: M19 accepted. +Status: accepted (2026-10-29T22:40:28+02:00). Prerequisite: M19 accepted (2026-10-29T22:24:30+02:00). Coordinator model GPT-6-Sol/medium confirmed earlier in this session by the user; worker dispatches selected GPT-6-Luna/high explicitly. Initial tree clean at `ede45d224a9ad255a919900e556a03d7e575430f`; pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 used. #### Outcome and scope @@ -3936,9 +3936,9 @@ Remove name ambiguity and opaque tuples without moving business rules. Planned o | Task | Owner | State | Bounded deliverable | | --- | --- | --- | --- | -| M20.1 | implementer | pending | Rename conflicting knowledge/observation types and migrate callers | -| M20.2 | implementer | pending | Replace prepared-operation tuples with private named structures | -| M20.3 | reviewer; repairer; coordinator | pending | Review semantic equivalence, repair, RUST acceptance | +| M20.1 | implementer `/root/m20_vocab` | complete | Renamed domain knowledge to ApplyStatus, removed duplicate TUI enum and unnecessary observation aliases, migrated callers; focused tests passed | +| M20.2 | implementer `/root/m20_vocab` | complete | Replaced prepared-operation tuples with private named contexts/mode; focused behavior and workflow tests passed | +| M20.3 | fresh reviewer `/root/m20_review`; repairer `/root/m20_repair`; coordinator | complete | M20-R1 repaired and closed on independent re-review; final integrated RUST gates passed | 1. Rename the domain `RuntimeObservation` (currently Unknown/Unavailable/Verified) to the M19 canonical knowledge name, such as `ApplyStatus`; retain runtime::RuntimeObservation for measured interface data. Decide whether tui::AppliedState is a necessary presentation mapping or an exact redundant copy. Remove only redundant representation; do not merge raw statistics and verification status. Inventory every `as Statistics`/`as TechnicalObservation` alias and change only aliases made unnecessary by clarified names. 2. Replace ApplicationExecutor.prepared_destination's six-element tuple with named fields for identity, QR choice, delivery mode, retry attempt, generation and PreparedDestination. Replace restore_preview's tuple similarly. Preserve secret ownership, clear-on-unrelated-command behavior, stale-generation refusal, overwrite confirmation and cancellation. Avoid adding Clone/Debug to secret-bearing types. @@ -3947,11 +3947,13 @@ Remove name ambiguity and opaque tuples without moving business rules. Planned o #### Acceptance and validation -- [ ] M20-A1: present apply knowledge and measured observation have distinct canonical names; no accidental meaning change in UI labels. -- [ ] M20-A2: prepared contexts use named fields; generation/cancel/confirmation behavior and secret redaction remain equivalent. -- [ ] M20-A3: actual consumers compile, dependencies/formats untouched; focused worker/state tests and RUST pass after independent review. +- [x] M20-A1: present apply knowledge and measured observation have distinct canonical names; UI labels retain their meaning. +- [x] M20-A2: prepared contexts use named fields; generation/cancel/confirmation behavior and secret redaction remain equivalent. +- [x] M20-A3: actual consumers compile, dependencies/formats untouched; focused worker/state tests and final RUST gates passed after independent review. -Focused starting commands: `cargo test --locked domain::`, `cargo test --locked runtime::`, `cargo test --locked tui::events::`, `cargo test --locked tui::state::`; adjust filters only to actual moved modules and report executed tests. Evidence: docs/evidence/milestone-20.md. Outcome: not accepted. Handoff: M21 only after acceptance. +Review and repair: Fresh independent source review found no behavior findings. M20-R1 (P2, `src/app.rs` test and `src/container_driver.rs` test-only path) is closed: repairer replaced two `clone_on_copy` calls with dereferences; independent re-review and affected tests/Clippy passed. + +Evidence/handoff: [M20 evidence](docs/evidence/milestone-20.md) records the clean base, dirty source patch/artifact fingerprints, focused tests, R1 closure, exact final checks and limits. Final coordinator commands `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked` (391 passed, 3 ignored plus 2 doctests) and `cargo build --locked --release` all exited 0; `git diff --check` passed. Outcome: accepted for host/synthetic scope; stop after M20. Next action only on a separate request is M21.1; no fresh Alpine/VM result is claimed. ### Milestone 21 — Domain ownership of reservations and apply knowledge @@ -4157,7 +4159,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af ## Current progress and resume point -- M19 is accepted (2026-10-29T22:24:30+02:00): glossary and source-referenced migration contract passed independent R1–R6 closure and integrated DOC checks; [M19 evidence](docs/evidence/milestone-19.md) records inputs and limits. Stop after M19; M20–M27 remain pending. M18 remains accepted; M9 remains retired and real-system limitations remain unchanged. +- M20 is accepted (2026-10-29T22:40:28+02:00): renamed apply knowledge, named prepared contexts, independent M20-R1 closure and final RUST gates passed; [M20 evidence](docs/evidence/milestone-20.md) records source/artifact identity and limits. Stop after M20; M21–M27 remain pending. M19 remains accepted; M9 remains retired/unaccepted and its real-system limitations remain unchanged. - M18 was accepted on 2026-10-29: structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. At that handoff, M19 had not started. @@ -4186,7 +4188,7 @@ Evidence: docs/evidence/milestone-27.md. Outcome: not accepted. Handoff: stop af - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. -- M18 handoff remains complete. M19 is accepted; the next implementation action, when separately requested, is M20.1. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. +- M18/M19 handoffs remain complete. M20 is accepted; the next implementation action, when separately requested, is M21.1. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work. ## Plan maintenance diff --git a/docs/evidence/milestone-20.md b/docs/evidence/milestone-20.md new file mode 100644 index 0000000..5c5a4fc --- /dev/null +++ b/docs/evidence/milestone-20.md @@ -0,0 +1,80 @@ +# Milestone 20 evidence: vocabulary and prepared contexts + +Recorded 2026-10-29T22:38:53+02:00. Base commit +`ede45d224a9ad255a919900e556a03d7e575430f` had a clean working tree. +The final tree is dirty only for this milestone's `PLANS.md`, Rust source and +test changes, and this evidence report. No `Cargo.toml` or `Cargo.lock` diff, +new dependency, persisted-format change, production configuration, or secret +fixture was introduced. The final Rust source/test patch fingerprint is +SHA256 `cba97207f926f1927c70643754db2743603e0f72892115ef8d959696d769992a`, +produced by: + +```sh +git diff -- src/app.rs src/domain.rs src/persistence.rs src/container_driver.rs src/tui/events.rs src/tui/render.rs src/tui/state.rs src/tui/terminal.rs tests/client_delivery.rs tests/commit_restore.rs tests/runtime_restart.rs tests/tui_workflow.rs | sha256sum +``` + +The release artifact `target/release/wireguard-admin` built from those inputs +has SHA256 `7a6ee46b1d9c39bac9a7b3627b0b8ba30e70321126ebd16b1948b42b8658ac98`. +`file target/release/wireguard-admin` identified an x86-64 dynamically linked +GNU/Linux PIE with interpreter `/lib64/ld-linux-x86-64.so.2`. + +## Implementation and acceptance mapping + +- M20.1 renamed the domain's current knowledge enum to `ApplyStatus`, retained + `runtime::RuntimeObservation` for measured interface data, and migrated actual + application, persistence, TUI and integration callers. The exact duplicate + TUI `AppliedState` enum/mapping and now unnecessary `Statistics`, + `TechnicalObservation` and `ObservedRuntime` aliases were removed. UI labels + and current-versus-historical state transitions were preserved (M20-A1). +- M20.2 replaced the six-value prepared delivery tuple and three-value restore + tuple with private named contexts. A private `DeliveryMode` distinguishes + first issue and reissue without changing public `reissue` flags. Neither + context derives `Clone` or `Debug`; identity, attempt, generation, overwrite, + cancellation, unrelated-command clearing, password ownership and QR/file + order remain unchanged. A behavioral test covers cancel/unrelated-command + clearing (M20-A2). +- All affected production and test callers compile; no manifests, formats or + transport behavior changed. Focused tests, final full RUST gates and + independent review passed (M20-A3). + +## Independent review and repair + +A GPT-6-Luna/high implementer completed M20.1 and M20.2 sequentially. A +separate fresh GPT-6-Luna/high read-only reviewer inspected the integrated +diff, production paths, callers, tests and secret-bearing contexts and found +no behavior issue. The coordinator's first Clippy gate found M20-R1 (P2): two +test/test-only `.clone()` calls on the now-`Copy` status. A separate +GPT-6-Luna/high repairer changed only those two calls to dereferences in +`src/app.rs` and `src/container_driver.rs`. The reviewer independently +rechecked the repair and affected tests, closed R1 and found no new issue. +No actionable finding remains. + +## Checks and limits + +Environment at execution: `rustc`/Cargo 1.97.1 on +`x86_64-unknown-linux-gnu`, rustfmt 1.9.0 and Clippy 0.1.97, matching the +pinned compiler. Focused implementer/reviewer runs covered `domain::` (13), +`runtime::` (25), `tui::state::` (7), `tui::events::` (7), +`--test commit_restore` (9), `--test runtime_restart` (3), +`--test tui_workflow` (8), and `--test client_delivery` (5). After R1, the +repairer ran `cargo test --locked app::` (58 passed) and +`cargo test --locked --lib container_driver::` (23 passed, 2 ignored). The +reviewer reran affected app/event tests. These are host synthetic/PTY checks; +ignored Alpine selectors were not run. + +| Final coordinator command | Actual result | +| --- | --- | +| `cargo fmt --all -- --check` | exit 0 | +| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 after M20-R1 repair | +| `cargo test --locked` | exit 0: 391 unit/integration tests passed, 3 ignored; 2 doctests passed; 0 failed | +| `cargo build --locked --release` | exit 0; artifact SHA256 above | +| `git diff --check` | exit 0 | + +The initial Clippy run exited 101 on M20-R1 before repair and is not the final +gate result. Default tests did not execute the three explicitly ignored +Alpine/selected-daemon tests. No Alpine Testcontainers or disposable systemd VM +run was required for this naming/context refactor or performed. Prior Alpine +results are historical for earlier source and do not validate this artifact; +real systemd/D-Bus and IPv6 limitations remain as previously recorded. No +production, live VPN, host service or host network operation was performed. + diff --git a/src/app.rs b/src/app.rs index 3bb0797..4744b51 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,15 +19,12 @@ use crate::delivery::files::{self, FileOutputError, PreparedDestination}; use crate::delivery::qr; use crate::delivery::{self, ClientConfigPayload, DeliverySettings, QrCodeOutput, QrError}; use crate::domain::{ - AddressAvailability, Client, ClientAddresses, ClientStates, DomainError, PublicKey, - RuntimeObservation, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, - validate_manual_ipv6, + AddressAvailability, ApplyStatus, Client, ClientAddresses, ClientStates, DomainError, + PublicKey, Snapshot, allocate_ipv4, allocate_ipv6, validate_manual_ipv4, validate_manual_ipv6, }; use crate::persistence::{BackupComplete, PersistedState, PersistenceError, SavedStore}; use crate::runtime::transport::TransportError; -use crate::runtime::{ - self, RuntimeObservation as TechnicalObservation, SavedPeerProjection, TechnicalMatch, -}; +use crate::runtime::{self, RuntimeObservation, SavedPeerProjection, TechnicalMatch}; use crate::service::{self, RestartDeadline, RestartExecution, RestartOutcome}; use crate::vault::source::{ PreparedImportSetup, SelectedFileImportReport, SourceImportError, SourceImportReport, @@ -504,7 +501,7 @@ impl Application { let states = ClientStates::new( snapshot_from_document(&document), None, - RuntimeObservation::Unknown, + ApplyStatus::Unknown, )?; Self::open_saved(store, saved, states) } @@ -984,7 +981,7 @@ impl Application { let Ok((projection, _)) = restart_projection_from_document(document) else { return false; }; - self.states.applied() == &RuntimeObservation::Verified + self.states.applied() == &ApplyStatus::Verified && self.verified_saved_projection.as_ref() == Some(&projection) } @@ -1487,7 +1484,7 @@ impl Application { preview: RestartPreview, choice: RestartChoice, service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, - runtime_read: impl FnOnce(&str, Duration) -> Result, + runtime_read: impl FnOnce(&str, Duration) -> Result, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, ) -> RestartReport { self.restart_with_adapters_and_clock( @@ -1532,7 +1529,7 @@ impl Application { preview: RestartPreview, choice: RestartChoice, service: impl FnOnce(&str, RestartDeadline) -> RestartExecution, - runtime_read: impl FnOnce(&str, Duration) -> Result, + runtime_read: impl FnOnce(&str, Duration) -> Result, install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>, now: impl Fn() -> Instant, ) -> RestartReport { @@ -1860,7 +1857,7 @@ impl Application { let mut proposed = preview.saved_bytes; let document = ConfigDocument::parse(&proposed.server_config)?; let snapshot = snapshot_from_document(&document); - let mut restored_states = ClientStates::new(snapshot, None, RuntimeObservation::Unknown)?; + let mut restored_states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?; let mut archived_reservations = BTreeSet::new(); let mut reservations = self.states.reserved_addresses().clone(); // A restore can remove clients that are currently saved even when their @@ -1947,7 +1944,7 @@ impl Application { .map(VaultBundle::from_toml) .transpose()?; let snapshot = snapshot_from_document(&document); - let mut states = ClientStates::new(snapshot, None, RuntimeObservation::Unknown)?; + let mut states = ClientStates::new(snapshot, None, ApplyStatus::Unknown)?; if let Some(bytes) = &preview.saved_bytes.internal_state { PersistedState::from_toml(bytes)?.apply_to(&mut states)?; } @@ -2508,16 +2505,14 @@ mod tests { use crate::backup::{BackupReason, HistoricalApplyStatus, create_archive, preview_archive}; use crate::config::SettingsFile; use crate::delivery::qr::QrError; - use crate::domain::{ - Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, - }; + use crate::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot}; use crate::persistence::{ BackupComplete, InstallCheckpoint, PersistedState, PersistenceError, SavedStore, }; use crate::runtime::transport::TransportError; use crate::runtime::{ - InterfaceObservation, Observed, PeerSetCompleteness, RuntimeObservation as ObservedRuntime, - RuntimePeer, RuntimePeerSet, + InterfaceObservation, Observed, PeerSetCompleteness, RuntimeObservation, RuntimePeer, + RuntimePeerSet, }; use crate::service::{RestartExecution, RestartFailure, RestartOutcome}; use crate::vault::{VaultBundle, WireGuardPrivateKey}; @@ -2586,12 +2581,8 @@ restart_timeout_seconds = 30 ) .expect("state client valid"); let snapshot = Snapshot::new([saved_client]).expect("snapshot valid"); - let states = ClientStates::new( - snapshot.clone(), - Some(snapshot), - RuntimeObservation::Verified, - ) - .expect("initial applied state valid"); + let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified) + .expect("initial applied state valid"); let store = SavedStore::new(root, resolved).expect("saved store mapping valid"); (store, states, key, secret) } @@ -2637,7 +2628,7 @@ restart_timeout_seconds = 30 app } - fn observed_runtime(app: &Application) -> ObservedRuntime { + fn observed_runtime(app: &Application) -> RuntimeObservation { let records = app.document().peers().iter().map(|peer| { let identity = peer.identity.expect("synthetic peer identity"); ( @@ -2650,7 +2641,7 @@ restart_timeout_seconds = 30 }, ) }); - ObservedRuntime { + RuntimeObservation { observed_at: None, interface: InterfaceObservation::Present { name: "wg0".to_owned(), @@ -3590,7 +3581,7 @@ restart_timeout_seconds = 30 String::from_utf8_lossy(app.document().render()) .contains("# wg-admin:alias=bmV3IGFsaWFz") ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!( app.states().saved().get(&key).expect("saved peer").alias, "legacy-view" @@ -3681,7 +3672,7 @@ restart_timeout_seconds = 30 app.states().saved().get(&key).expect("saved peer").alias, "new alias" ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(app.states().applied_snapshot().is_some()); let backup = preview_archive(&archive).expect("pre-commit backup parses"); assert_eq!(backup.reason, BackupReason::PreCommit); @@ -3769,11 +3760,10 @@ restart_timeout_seconds = 30 assert!(state.reserved_addresses.contains(&"10.20.0.2".to_owned())); assert!(state.reserved_addresses.contains(&"fd20::2".to_owned())); - let reopened_states = - ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) - .expect("empty supported snapshot is valid"); + let reopened_states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown) + .expect("empty supported snapshot is valid"); let reopened = Application::open(store, reopened_states).expect("session reopens"); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); assert!( reopened .states() @@ -3912,7 +3902,7 @@ restart_timeout_seconds = 30 app.confirm_restore(preview) .expect("restore uses previewed bytes"); assert!(!String::from_utf8_lossy(app.document().render()).contains("draft alias")); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(app.vault().is_some()); assert!(app.states().saved().get(&key).is_some()); } @@ -3943,7 +3933,7 @@ restart_timeout_seconds = 30 let before_restore = store.read_saved().expect("current saved bytes"); app.rename_client(key, "draft survives failed restore") .expect("stage draft"); - let applied_before = app.states().applied().clone(); + let applied_before = *app.states().applied(); let preview = app .preview_restore(selected.file_name().unwrap().to_str().unwrap()) .expect("selected archive preview"); @@ -4106,7 +4096,7 @@ restart_timeout_seconds = 30 .expect("restored reservation state parses"); assert!(state.reserved_addresses.contains(&"fd20::2".to_owned())); assert!(app.vault().is_none()); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -4167,7 +4157,7 @@ restart_timeout_seconds = 30 .iter() .all(|item| item.historical_apply == HistoricalApplyStatus::Unknown) ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -4284,7 +4274,7 @@ restart_timeout_seconds = 30 assert!(report.backup_path.is_some()); assert!(report.restart_submitted); assert_eq!(*events.borrow(), vec!["service", "runtime", "persist"]); - assert_eq!(app.states().applied(), &RuntimeObservation::Verified); + assert_eq!(app.states().applied(), &ApplyStatus::Verified); assert_eq!( app.states().draft().get(&key).unwrap().alias, "restart fixture" @@ -4352,7 +4342,7 @@ restart_timeout_seconds = 30 ); assert_eq!(report.stage, RestartStage::Complete); - assert_eq!(app.states().applied(), &RuntimeObservation::Verified); + assert_eq!(app.states().applied(), &ApplyStatus::Verified); assert!(app.states().applied_snapshot().is_none()); assert!(app.states().reserved_addresses().is_empty()); assert_eq!(app.saved_bytes().server_config, original_config); @@ -4364,7 +4354,7 @@ restart_timeout_seconds = 30 let reopened = Application::open_from_saved_store(app.store.clone()) .expect("reopen historically verified saved state"); assert_eq!(reopened.historical_apply, historical_apply); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); assert!(reopened.states().applied_snapshot().is_none()); assert!(reopened.states().reserved_addresses().is_empty()); assert!( @@ -4471,12 +4461,12 @@ restart_timeout_seconds = 30 assert_eq!(report.stage, RestartStage::Verification); assert_eq!(report.failure, Some(RestartFailureKind::RuntimeMismatch)); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!(app.states().reserved_addresses(), &reserved_before); let reopened = Application::open_from_saved_store(app.store.clone()) .expect("failed verification leaves saved reservations durable"); assert_eq!(reopened.states().reserved_addresses(), &reserved_before); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -4590,7 +4580,7 @@ restart_timeout_seconds = 30 assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!runtime_called.get()); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } @@ -4641,7 +4631,7 @@ restart_timeout_seconds = 30 assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!install_called.get()); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } @@ -4697,7 +4687,7 @@ restart_timeout_seconds = 30 assert!(!report.restart_may_still_complete); assert!(report.manual_recovery_required); assert!(!install_called.get()); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } } @@ -4844,7 +4834,7 @@ restart_timeout_seconds = 30 | RestartFailure::JobResultUnknown ) ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } } @@ -4865,7 +4855,7 @@ restart_timeout_seconds = 30 phase: crate::service::RestartPhase::WaitForJob, }, |_, _| { - Ok(ObservedRuntime { + Ok(RuntimeObservation { observed_at: None, interface: InterfaceObservation::Missing, }) @@ -4890,7 +4880,7 @@ restart_timeout_seconds = 30 ); assert_eq!(failed_write.stage, RestartStage::Persistence); assert!(failed_write.manual_recovery_required); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!(!app.states().reserved_addresses().is_empty()); } @@ -4983,7 +4973,7 @@ restart_timeout_seconds = 30 verified_at_utc: verified_at.to_owned() } ); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -5021,7 +5011,7 @@ restart_timeout_seconds = 30 Err(AppError::Persistence(PersistenceError::RecoveryRequired)) )); assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert_eq!(app.saved_bytes().internal_state, before.internal_state); let resolved = SettingsFile::parse_toml(SETTINGS) @@ -5036,7 +5026,7 @@ restart_timeout_seconds = 30 assert_eq!(recovered.internal_state, before.internal_state); let reopened = Application::open(reopened_store, states).expect("application reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -5074,7 +5064,7 @@ restart_timeout_seconds = 30 ); let reopened = Application::open(store, states).expect("committed state reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -5127,7 +5117,7 @@ restart_timeout_seconds = 30 let pre_restore = app.confirm_restore(preview).expect("restore succeeds"); assert_eq!(app.historical_apply, HistoricalApplyStatus::Unknown); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); let archived = preview_archive(&pre_restore).expect("pre-restore backup validates"); assert_eq!( archived.historical_apply, @@ -5146,11 +5136,11 @@ restart_timeout_seconds = 30 let reopened_states = ClientStates::new( snapshot_from_document(&restored_document), None, - RuntimeObservation::Unknown, + ApplyStatus::Unknown, ) .unwrap(); let reopened = Application::open(store, reopened_states).expect("restored state reopens"); assert_eq!(reopened.historical_apply, HistoricalApplyStatus::Unknown); - assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(reopened.states().applied(), &ApplyStatus::Unknown); } } diff --git a/src/container_driver.rs b/src/container_driver.rs index ae9e07d..c13032e 100644 --- a/src/container_driver.rs +++ b/src/container_driver.rs @@ -1047,7 +1047,7 @@ fn run_failure_prevents_application_scenario( let files_before_restart = protected_saved_file_snapshot(fixture_root)?; let reservations_before_restart = app.states().reserved_addresses().clone(); let history_before_restart = saved_last_verified_timestamp(app.saved_bytes())?; - let applied_before_restart = app.states().applied().clone(); + let applied_before_restart = *app.states().applied(); let applied_snapshot_before_restart = app.states().applied_snapshot().cloned(); let runtime_before_restart = observe_fixture_peer_set()?; let apply_called = std::cell::Cell::new(false); diff --git a/src/domain.rs b/src/domain.rs index 2c15626..3488ec5 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -146,9 +146,9 @@ impl Snapshot { } } -/// Runtime knowledge is not inferred from saved state or missing data. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum RuntimeObservation { +/// Current apply knowledge is not inferred from saved state or missing data. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ApplyStatus { Unknown, Unavailable, Verified, @@ -160,7 +160,7 @@ pub struct ClientStates { draft: Snapshot, saved: Snapshot, applied_snapshot: Option, - applied: RuntimeObservation, + applied: ApplyStatus, reserved_addresses: BTreeSet, } @@ -169,9 +169,9 @@ impl ClientStates { pub fn new( saved: Snapshot, applied_snapshot: Option, - applied: RuntimeObservation, + applied: ApplyStatus, ) -> Result { - if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) { + if applied == ApplyStatus::Verified && applied_snapshot.as_ref() != Some(&saved) { return Err(DomainError::AppliedSnapshotMismatch); } let saved_addresses = saved.addresses().collect::>(); @@ -199,7 +199,7 @@ impl ClientStates { &self.saved } - pub fn applied(&self) -> &RuntimeObservation { + pub fn applied(&self) -> &ApplyStatus { &self.applied } @@ -226,7 +226,7 @@ impl ClientStates { } self.reserved_addresses = reservations; self.applied_snapshot = None; - self.applied = RuntimeObservation::Unknown; + self.applied = ApplyStatus::Unknown; Ok(()) } @@ -243,10 +243,10 @@ impl ClientStates { .filter(|address| !next_addresses.contains(address)), ); self.saved = self.draft.clone(); - if self.applied == RuntimeObservation::Verified + if self.applied == ApplyStatus::Verified && self.applied_snapshot.as_ref() != Some(&self.saved) { - self.applied = RuntimeObservation::Unknown; + self.applied = ApplyStatus::Unknown; } } @@ -261,11 +261,11 @@ impl ClientStates { observed: Snapshot, ) -> Result<(), DomainError> { if observed != self.saved { - self.applied = RuntimeObservation::Unknown; + self.applied = ApplyStatus::Unknown; return Err(DomainError::AppliedSnapshotMismatch); } self.applied_snapshot = Some(observed); - self.applied = RuntimeObservation::Verified; + self.applied = ApplyStatus::Verified; self.reserved_addresses.clear(); Ok(()) } @@ -282,17 +282,17 @@ impl ClientStates { return self.mark_applied_after_verification(observed); } self.applied_snapshot = None; - self.applied = RuntimeObservation::Verified; + self.applied = ApplyStatus::Verified; self.reserved_addresses.clear(); Ok(()) } pub fn mark_runtime_unavailable(&mut self) { - self.applied = RuntimeObservation::Unavailable; + self.applied = ApplyStatus::Unavailable; } pub fn mark_runtime_unknown(&mut self) { - self.applied = RuntimeObservation::Unknown; + self.applied = ApplyStatus::Unknown; } /// Addresses in draft, saved, or last verified applied data cannot be reused. @@ -558,12 +558,12 @@ mod tests { } #[test] - fn unknown_and_unavailable_runtime_observations_are_explicit() { + fn unknown_and_unavailable_apply_statuses_are_explicit() { let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); - let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap(); - assert_eq!(unknown.applied(), &RuntimeObservation::Unknown); - let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap(); - assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable); + let unknown = ClientStates::new(saved.clone(), None, ApplyStatus::Unknown).unwrap(); + assert_eq!(unknown.applied(), &ApplyStatus::Unknown); + let unavailable = ClientStates::new(saved, None, ApplyStatus::Unavailable).unwrap(); + assert_eq!(unavailable.applied(), &ApplyStatus::Unavailable); } #[test] @@ -572,16 +572,16 @@ mod tests { let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap(); assert_eq!( - ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(), + ClientStates::new(saved.clone(), None, ApplyStatus::Verified).err(), Some(DomainError::AppliedSnapshotMismatch) ); assert_eq!( - ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(), + ClientStates::new(saved.clone(), Some(other), ApplyStatus::Verified).err(), Some(DomainError::AppliedSnapshotMismatch) ); let verified = - ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap(); - assert_eq!(verified.applied(), &RuntimeObservation::Verified); + ClientStates::new(saved.clone(), Some(saved), ApplyStatus::Verified).unwrap(); + assert_eq!(verified.applied(), &ApplyStatus::Verified); } #[test] @@ -591,12 +591,8 @@ mod tests { client(2, "two", "10.0.0.3", None), ]) .unwrap(); - let mut states = ClientStates::new( - saved.clone(), - Some(saved.clone()), - RuntimeObservation::Verified, - ) - .unwrap(); + let mut states = + ClientStates::new(saved.clone(), Some(saved.clone()), ApplyStatus::Verified).unwrap(); states.draft_mut().remove(&key(2)); states.discard_draft(); assert_eq!(states.draft(), &saved); @@ -604,10 +600,10 @@ mod tests { states.draft_mut().remove(&key(2)); states.commit_draft(); assert_eq!(states.saved().clients().count(), 1); - assert_eq!(states.applied(), &RuntimeObservation::Unknown); + assert_eq!(states.applied(), &ApplyStatus::Unknown); assert_eq!(states.applied_snapshot(), Some(&saved)); states.mark_runtime_unavailable(); - assert_eq!(states.applied(), &RuntimeObservation::Unavailable); + assert_eq!(states.applied(), &ApplyStatus::Unavailable); assert!(states.applied_snapshot().is_some()); assert!( states @@ -628,7 +624,7 @@ mod tests { #[test] fn a_draft_only_deletion_does_not_create_a_committed_reservation() { let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); - let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap(); + let mut states = ClientStates::new(saved, None, ApplyStatus::Unknown).unwrap(); states.draft_mut().remove(&key(1)); assert!(states.reserved_addresses().is_empty()); assert!( @@ -644,7 +640,7 @@ mod tests { let mut states = ClientStates::new( initial.clone(), Some(initial.clone()), - RuntimeObservation::Verified, + ApplyStatus::Verified, ) .unwrap(); let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); @@ -652,7 +648,7 @@ mod tests { states.mark_applied_after_verification(wrong), Err(DomainError::AppliedSnapshotMismatch) ); - assert_eq!(states.applied(), &RuntimeObservation::Unknown); + assert_eq!(states.applied(), &ApplyStatus::Unknown); assert_eq!(states.applied_snapshot(), Some(&initial)); states.draft_mut().remove(&key(1)); @@ -662,7 +658,7 @@ mod tests { states .mark_applied_after_verification(saved.clone()) .unwrap(); - assert_eq!(states.applied(), &RuntimeObservation::Verified); + assert_eq!(states.applied(), &ApplyStatus::Verified); assert!(states.reserved_addresses().is_empty()); assert!( !states diff --git a/src/persistence.rs b/src/persistence.rs index be956d8..795886d 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -953,22 +953,16 @@ restart_timeout_seconds = 30 ) .unwrap(); let saved = crate::domain::Snapshot::new([client]).unwrap(); - let mut states = crate::domain::ClientStates::new( - saved, - None, - crate::domain::RuntimeObservation::Unknown, - ) - .unwrap(); + let mut states = + crate::domain::ClientStates::new(saved, None, crate::domain::ApplyStatus::Unknown) + .unwrap(); let state = PersistedState::new( ["10.0.0.8".to_owned()], Some("2026-10-19T12:30:00Z".to_owned()), ) .unwrap(); state.apply_to(&mut states).unwrap(); - assert_eq!( - states.applied(), - &crate::domain::RuntimeObservation::Unknown - ); + assert_eq!(states.applied(), &crate::domain::ApplyStatus::Unknown); assert!( states .reserved_addresses() diff --git a/src/tui/events.rs b/src/tui/events.rs index 0b16c30..8a220c4 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -287,17 +287,30 @@ pub trait OperationExecutor: Send + 'static { } } +enum DeliveryMode { + FirstIssue, + Reissue, +} + +struct PreparedDestinationContext { + identity: PublicKey, + include_qr: bool, + mode: DeliveryMode, + attempt: FirstIssueAttempt, + generation: u64, + destination: PreparedDestination, +} + +struct RestorePreviewContext { + _archive_name: String, + generation: u64, + preview: RestorePreview, +} + #[derive(Default)] pub struct ApplicationExecutor { - prepared_destination: Option<( - PublicKey, - bool, - bool, - FirstIssueAttempt, - u64, - PreparedDestination, - )>, - restore_preview: Option<(String, u64, RestorePreview)>, + prepared_destination: Option, + restore_preview: Option, } impl OperationExecutor for ApplicationExecutor { @@ -379,14 +392,18 @@ impl OperationExecutor for ApplicationExecutor { attempt, prepared_generation: generation, }; - self.prepared_destination = Some(( + self.prepared_destination = Some(PreparedDestinationContext { identity, include_qr, - reissue, + mode: if reissue { + DeliveryMode::Reissue + } else { + DeliveryMode::FirstIssue + }, attempt, generation, destination, - )); + }); CommandResult::DeliveryDestination(summary) }), UiCommand::DeliverFirstIssue { @@ -394,23 +411,16 @@ impl OperationExecutor for ApplicationExecutor { overwrite_confirmed, prepared_generation, } => { - let ( - identity, - include_qr, - reissue, - prepared_attempt, - destination_generation, - mut destination, - ) = self + let prepared = self .prepared_destination .take() .ok_or_else(|| OperationError { message: "No prepared output destination is available.".to_owned(), })?; - if reissue - || prepared_attempt != attempt + if !matches!(prepared.mode, DeliveryMode::FirstIssue) + || prepared.attempt != attempt || !destination_generation_matches( - destination_generation, + prepared.generation, prepared_generation, generation, ) @@ -420,6 +430,9 @@ impl OperationExecutor for ApplicationExecutor { .to_owned(), }); } + let identity = prepared.identity; + let include_qr = prepared.include_qr; + let mut destination = prepared.destination; if destination.overwrite_confirmation_required() { if !overwrite_confirmed { return Err(OperationError { @@ -519,7 +532,11 @@ impl OperationExecutor for ApplicationExecutor { changes, preview_generation: generation, }; - self.restore_preview = Some((file_name, generation, preview)); + self.restore_preview = Some(RestorePreviewContext { + _archive_name: file_name, + generation, + preview, + }); CommandResult::RestorePreview(summary) }) } @@ -532,14 +549,12 @@ impl OperationExecutor for ApplicationExecutor { Ok(CommandResult::Cancelled) } UiCommand::ConfirmRestore { preview_generation } => { - let (_, stored_generation, preview) = - self.restore_preview.take().ok_or_else(|| OperationError { - message: - "Restore preview is no longer available; select the archive again." - .to_owned(), - })?; + let context = self.restore_preview.take().ok_or_else(|| OperationError { + message: "Restore preview is no longer available; select the archive again." + .to_owned(), + })?; if !restore_preview_generation_matches( - stored_generation, + context.generation, preview_generation, generation, ) { @@ -547,7 +562,8 @@ impl OperationExecutor for ApplicationExecutor { message: "Restore preview is stale; select the archive again.".to_owned(), }); } - app.confirm_restore(preview).map(CommandResult::Restored) + app.confirm_restore(context.preview) + .map(CommandResult::Restored) } }; result.map_err(|error| OperationError { @@ -573,28 +589,23 @@ impl OperationExecutor for ApplicationExecutor { overwrite_confirmed, prepared_generation, } => { - let ( - prepared_identity, - _include_qr, - reissue, - _attempt, - destination_generation, - mut destination, - ) = self + let prepared = self .prepared_destination .take() .ok_or_else(|| OperationError { message: "No prepared output destination is available.".to_owned(), })?; - if prepared_identity != identity - || !reissue - || destination_generation != prepared_generation + if prepared.identity != identity + || !matches!(prepared.mode, DeliveryMode::Reissue) + || prepared.generation != prepared_generation { return Err(OperationError { message: "Prepared output destination is stale; select it again." .to_owned(), }); } + let include_qr = prepared.include_qr; + let mut destination = prepared.destination; if destination.overwrite_confirmation_required() { if !overwrite_confirmed { return Err(OperationError { @@ -604,7 +615,7 @@ impl OperationExecutor for ApplicationExecutor { destination = destination.confirm_overwrite().map_err(operation_error)?; } let delivery = app - .reissue_client_file(identity, password.expose(), destination, _include_qr) + .reissue_client_file(identity, password.expose(), destination, include_qr) .map_err(|error| OperationError { message: error.to_string(), })?; @@ -1088,6 +1099,58 @@ mod tests { assert!(!restore_preview_generation_matches(8, 8, 8)); } + #[test] + fn prepared_delivery_is_cleared_by_cancel_and_unrelated_commands() { + let (_root, mut app) = application(); + let identity = PublicKey::from_bytes([44; 32]); + let mut executor = ApplicationExecutor::default(); + let path = _root.path().join("prepared.conf"); + let prepare = || UiCommand::PrepareDelivery { + identity, + path: path.clone(), + include_qr: false, + reissue: false, + attempt: FirstIssueAttempt::Initial, + }; + let deliver = || UiCommand::DeliverFirstIssue { + attempt: FirstIssueAttempt::Initial, + overwrite_confirmed: false, + prepared_generation: 7, + }; + + assert!(matches!( + executor.execute_at(&mut app, prepare(), 7), + Ok(CommandResult::DeliveryDestination(_)) + )); + assert!(matches!( + executor.execute_at(&mut app, UiCommand::CancelPreparedDelivery, 7), + Ok(CommandResult::Cancelled) + )); + assert_eq!( + executor + .execute_at(&mut app, deliver(), 7) + .unwrap_err() + .message, + "No prepared output destination is available." + ); + + assert!(matches!( + executor.execute_at(&mut app, prepare(), 7), + Ok(CommandResult::DeliveryDestination(_)) + )); + assert!(matches!( + executor.execute_at(&mut app, UiCommand::ListBackups, 7), + Ok(CommandResult::Backups(_)) + )); + assert_eq!( + executor + .execute_at(&mut app, deliver(), 7) + .unwrap_err() + .message, + "No prepared output destination is available." + ); + } + struct FakeStatistics { started: Option>, release: Option>, diff --git a/src/tui/render.rs b/src/tui/render.rs index b655a50..a07e6f5 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -654,9 +654,9 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option { .filter(|row| row.saved.is_some()) .count(), match view.applied { - super::state::AppliedState::Verified => "verified", - super::state::AppliedState::Unknown => "not verified", - super::state::AppliedState::Unavailable => "observation unavailable", + crate::domain::ApplyStatus::Verified => "verified", + crate::domain::ApplyStatus::Unknown => "not verified", + crate::domain::ApplyStatus::Unavailable => "observation unavailable", } )) } @@ -1210,9 +1210,9 @@ fn persistent_state_line(view: &SessionView) -> String { .filter(|row| row.saved.is_some()) .count(); let applied = match view.applied { - super::state::AppliedState::Verified => "verified", - super::state::AppliedState::Unknown => "not verified", - super::state::AppliedState::Unavailable => "unavailable", + crate::domain::ApplyStatus::Verified => "verified", + crate::domain::ApplyStatus::Unknown => "not verified", + crate::domain::ApplyStatus::Unavailable => "unavailable", }; format!( "Draft: {draft}; Saved peers: {saved_peers} / managed {saved_managed}; Applied(saved): {applied}" @@ -1797,16 +1797,16 @@ fn saved_status(view: &SessionView) -> String { fn applied_status(view: &SessionView) -> String { match view.applied { - super::state::AppliedState::Verified if view.has_draft => { + crate::domain::ApplyStatus::Verified if view.has_draft => { "Applied: verified for saved configuration (draft excluded)".to_owned() } - super::state::AppliedState::Verified => { + crate::domain::ApplyStatus::Verified => { "Applied: verified for saved configuration".to_owned() } - super::state::AppliedState::Unknown => { + crate::domain::ApplyStatus::Unknown => { "Applied: not verified for saved configuration".to_owned() } - super::state::AppliedState::Unavailable => "Applied: observation unavailable".to_owned(), + crate::domain::ApplyStatus::Unavailable => "Applied: observation unavailable".to_owned(), } } @@ -2329,7 +2329,7 @@ mod tests { client_states: BTreeMap::new(), saved_peer_count: Some(0), document_issues: Vec::new(), - applied: super::super::state::AppliedState::Unknown, + applied: crate::domain::ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: true, @@ -2545,7 +2545,7 @@ mod tests { )]), saved_peer_count: Some(1), document_issues: Vec::new(), - applied: super::super::state::AppliedState::Unknown, + applied: crate::domain::ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, @@ -2558,13 +2558,13 @@ mod tests { let identity = PublicKey::from_bytes([61; 32]); let mut view = saved_client_view(identity); view.has_draft = false; - view.applied = super::super::state::AppliedState::Verified; + view.applied = crate::domain::ApplyStatus::Verified; let summary = state_summary(&view); assert!(summary.contains("Draft: no changes")); assert!(summary.contains("Saved: 1 peer sections; 1 managed clients")); assert!(summary.contains("Applied: verified for saved configuration")); - view.applied = super::super::state::AppliedState::Unknown; + view.applied = crate::domain::ApplyStatus::Unknown; assert_eq!( applied_status(&view), "Applied: not verified for saved configuration" @@ -2572,7 +2572,7 @@ mod tests { assert!(!state_summary(&view).contains("restart pending")); view.has_draft = true; - view.applied = super::super::state::AppliedState::Verified; + view.applied = crate::domain::ApplyStatus::Verified; assert!( draft_status(&view) .contains("Draft: pending; draft peers: 1; managed-client changes: 0") @@ -2619,13 +2619,13 @@ mod tests { ); assert!(draft_status(&view).contains("managed-client changes: 2")); - view.applied = super::super::state::AppliedState::Unknown; + view.applied = crate::domain::ApplyStatus::Unknown; assert_eq!( applied_status(&view), "Applied: not verified for saved configuration" ); assert!(!state_summary(&view).contains("restart pending")); - view.applied = super::super::state::AppliedState::Unavailable; + view.applied = crate::domain::ApplyStatus::Unavailable; assert_eq!(applied_status(&view), "Applied: observation unavailable"); let mut state = UiState { @@ -2637,7 +2637,7 @@ mod tests { assert_eq!(state_summary(state.view.as_ref().unwrap()), before); assert_eq!( state.view.as_ref().unwrap().applied, - super::super::state::AppliedState::Unavailable + crate::domain::ApplyStatus::Unavailable ); } @@ -3932,7 +3932,7 @@ mod tests { )]), saved_peer_count: Some(1), document_issues: Vec::new(), - applied: super::super::state::AppliedState::Unknown, + applied: crate::domain::ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, @@ -4055,7 +4055,7 @@ mod tests { ]), saved_peer_count: Some(2), document_issues: vec![], - applied: super::super::state::AppliedState::Unknown, + applied: crate::domain::ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: true, diff --git a/src/tui/state.rs b/src/tui/state.rs index 1aaffaf..dbb941e 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -4,8 +4,8 @@ use std::collections::BTreeSet; use std::net::IpAddr; use crate::app::{Application, ClientDisplay}; -use crate::domain::{Client, PublicKey, RuntimeObservation}; -use crate::runtime::RuntimeObservation as Statistics; +use crate::domain::{ApplyStatus, Client, PublicKey}; +use crate::runtime::RuntimeObservation; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Screen { @@ -34,13 +34,6 @@ pub enum DialogKind { Exit, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AppliedState { - Unknown, - Unavailable, - Verified, -} - /// Public client values for both states. No configuration bytes or secrets are /// retained in a view snapshot. #[derive(Clone, Debug, Eq, PartialEq)] @@ -58,9 +51,9 @@ pub struct SessionView { /// saved bytes can be projected. This includes nonmanaged peers. pub saved_peer_count: Option, pub document_issues: Vec, - pub applied: AppliedState, + pub applied: ApplyStatus, pub reserved_addresses: BTreeSet, - pub statistics: Statistics, + pub statistics: RuntimeObservation, pub has_draft: bool, pub vault_initialized: bool, } @@ -83,11 +76,6 @@ impl SessionView { ) }) .collect(); - let applied = match states.applied() { - RuntimeObservation::Unknown => AppliedState::Unknown, - RuntimeObservation::Unavailable => AppliedState::Unavailable, - RuntimeObservation::Verified => AppliedState::Verified, - }; let projection = app.display_projection(); let saved_peer_count = crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config) @@ -99,9 +87,9 @@ impl SessionView { client_states, saved_peer_count, document_issues: projection.document_issues, - applied, + applied: *states.applied(), reserved_addresses: states.reserved_addresses().clone(), - statistics: Statistics::unknown(), + statistics: RuntimeObservation::unknown(), has_draft: app.has_draft_changes(), vault_initialized: app.vault_initialized(), } @@ -318,7 +306,7 @@ impl UiState { /// Apply only statistics sampled from the still-current saved/draft view. /// This never changes the applied-state marker. - pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool { + pub fn apply_statistics(&mut self, generation: u64, statistics: RuntimeObservation) -> bool { if generation != self.generation { return false; } @@ -521,9 +509,9 @@ mod tests { client_states: Default::default(), saved_peer_count: None, document_issues: Vec::new(), - applied: AppliedState::Unknown, + applied: ApplyStatus::Unknown, reserved_addresses: BTreeSet::new(), - statistics: Statistics::unknown(), + statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, } @@ -669,9 +657,9 @@ mod tests { let key = PublicKey::from_bytes([4; 32]); let mut state = UiState::default(); assert!(state.apply_view(view(0, vec![row(key, "client", true)]))); - assert!(state.apply_statistics(0, Statistics::unknown())); - assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown); + assert!(state.apply_statistics(0, RuntimeObservation::unknown())); + assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Unknown); state.generation = 1; - assert!(!state.apply_statistics(0, Statistics::unknown())); + assert!(!state.apply_statistics(0, RuntimeObservation::unknown())); } } diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs index 1c6a463..aa5b4e6 100644 --- a/src/tui/terminal.rs +++ b/src/tui/terminal.rs @@ -960,9 +960,10 @@ mod tests { use ratatui::backend::TestBackend; use crate::app::ClientDisplay; + use crate::domain::ApplyStatus; use crate::runtime::RuntimeObservation; use crate::tui::events::OperationError; - use crate::tui::state::{AppliedState, OperationKind, SessionView}; + use crate::tui::state::{OperationKind, SessionView}; use crate::wireguard::PeerIssue; struct FakeWorker { @@ -1032,7 +1033,7 @@ mod tests { client_states: Default::default(), saved_peer_count: Some(0), document_issues: Vec::new(), - applied: AppliedState::Unknown, + applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, @@ -1163,7 +1164,7 @@ mod tests { let identity = crate::domain::PublicKey::from_bytes([1; 32]); let mut view = navigation_view(); view.has_draft = true; - view.applied = AppliedState::Verified; + view.applied = ApplyStatus::Verified; let mut state = UiState { screen: Screen::Help, selected_identity: Some(identity), @@ -1192,7 +1193,7 @@ mod tests { assert_eq!(state.selected_peer_index, selected_peer_index); assert_eq!(state.view, view_before); assert!(state.view.as_ref().unwrap().has_draft); - assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Verified); + assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Verified); // The scrolling helper accepts no worker or command sink, so Help input // cannot submit work or mutate saved/applied application state. assert!(!handle_help_navigation_key( @@ -1313,7 +1314,7 @@ mod tests { document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 1, }], - applied: AppliedState::Unknown, + applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, @@ -1344,7 +1345,7 @@ mod tests { client_states: Default::default(), saved_peer_count: Some(2), document_issues: Vec::new(), - applied: AppliedState::Unknown, + applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, @@ -1578,7 +1579,7 @@ mod tests { client_states: Default::default(), saved_peer_count: Some(0), document_issues: Vec::new(), - applied: AppliedState::Unknown, + applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, diff --git a/tests/client_delivery.rs b/tests/client_delivery.rs index 9e31e01..3b9a556 100644 --- a/tests/client_delivery.rs +++ b/tests/client_delivery.rs @@ -7,9 +7,7 @@ use wireguard_admin::app::{ }; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::delivery::files::FileOutputError; -use wireguard_admin::domain::{ - Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot, -}; +use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot}; use wireguard_admin::persistence::SavedStore; use wireguard_admin::vault::import::{parse_client_config, reconcile}; use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; @@ -59,7 +57,7 @@ fn fixture(server: String, states: ClientStates) -> Fixture { } fn empty_states() -> ClientStates { - ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown).expect("empty state") + ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty state") } fn open(fixture: &Fixture) -> Application { @@ -265,7 +263,7 @@ fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks let snapshot = Snapshot::new([client]).expect("snapshot"); let import_fixture = fixture( server.clone(), - ClientStates::new(snapshot, None, RuntimeObservation::Unknown).expect("client state"), + ClientStates::new(snapshot, None, ApplyStatus::Unknown).expect("client state"), ); let document = ConfigDocument::parse(server.as_bytes()).expect("server document"); let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key"); diff --git a/tests/commit_restore.rs b/tests/commit_restore.rs index 643a155..fd6cc16 100644 --- a/tests/commit_restore.rs +++ b/tests/commit_restore.rs @@ -6,7 +6,7 @@ use wireguard_admin::app::{AppError, Application}; use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes}; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::domain::{ - Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot, + ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, Snapshot, }; use wireguard_admin::persistence::{PersistedState, SavedStore}; use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; @@ -75,12 +75,8 @@ fn fixture(with_client: bool) -> Fixture { ) .expect("server fixture is private"); let snapshot = Snapshot::new(clients).expect("initial snapshot"); - let states = ClientStates::new( - snapshot.clone(), - Some(snapshot), - RuntimeObservation::Verified, - ) - .expect("initial states"); + let states = ClientStates::new(snapshot.clone(), Some(snapshot), ApplyStatus::Verified) + .expect("initial states"); let store = SavedStore::new(root.path(), settings.clone()).expect("saved store"); Fixture { root, @@ -240,7 +236,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes( .expect("new client"); app.add_client(client, None).expect("stage client"); app.commit().expect("save client"); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); let preview = app .preview_restore(archive_name(&empty_archive)) @@ -264,7 +260,7 @@ fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes( .contains(&"fd20::9".to_owned()) ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); assert!( app.states() .reserved_addresses() @@ -520,5 +516,5 @@ fn deletion_uses_public_key_and_removes_only_that_vault_record() { .expect("vault parses"); assert!(!bundle.contains_record(fixture.key)); assert!(bundle.contains_record(retained_key)); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } diff --git a/tests/runtime_restart.rs b/tests/runtime_restart.rs index ce90803..4da917d 100644 --- a/tests/runtime_restart.rs +++ b/tests/runtime_restart.rs @@ -4,9 +4,7 @@ use std::path::Path; use wireguard_admin::app::{Application, RestartChoice, RestartStage}; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; -use wireguard_admin::domain::{ - Client, ClientAddresses, ClientStates, RuntimeObservation, Snapshot, -}; +use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot}; use wireguard_admin::persistence::SavedStore; use wireguard_admin::vault::WireGuardPrivateKey; @@ -67,7 +65,7 @@ fn fixture(service: &str, timeout: u64) -> Fixture { } fn open(fixture: &Fixture) -> Application { - let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) + let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown) .expect("empty saved states"); Application::open(fixture.store.clone(), states).expect("open synthetic application") } @@ -121,7 +119,7 @@ fn public_restart_preview_and_cancel_preserve_draft_and_saved_files() { .expect("no cancellation backup") .is_empty() ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); } #[test] @@ -174,7 +172,7 @@ fn restore_changes_the_settings_used_by_the_next_restart_preview() { app.saved_bytes().settings, fixture.initial_settings.as_bytes() ); - assert_eq!(app.states().applied(), &RuntimeObservation::Unknown); + assert_eq!(app.states().applied(), &ApplyStatus::Unknown); let restart = app .preview_restart() .expect("preview restored generation without invoking adapters"); diff --git a/tests/tui_workflow.rs b/tests/tui_workflow.rs index e2f6d84..01e011a 100644 --- a/tests/tui_workflow.rs +++ b/tests/tui_workflow.rs @@ -13,9 +13,9 @@ use crossterm::event::KeyCode; use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage}; use wireguard_admin::backup::BackupReason; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; -use wireguard_admin::domain::{ClientStates, PublicKey, RuntimeObservation, Snapshot}; +use wireguard_admin::domain::{ApplyStatus, ClientStates, PublicKey, Snapshot}; use wireguard_admin::persistence::SavedStore; -use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation as Statistics}; +use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation}; use wireguard_admin::tui::events::{ ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand, ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent, @@ -106,9 +106,9 @@ fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() { struct CountingStatistics(Arc); impl StatisticsReader for CountingStatistics { - fn read(&mut self, _interface: &str, _timeout: Duration) -> Statistics { + fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { self.0.fetch_add(1, Ordering::SeqCst); - Statistics { + RuntimeObservation { observed_at: None, interface: InterfaceObservation::Unavailable, } @@ -116,8 +116,8 @@ impl StatisticsReader for CountingStatistics { } fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker { - let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) - .expect("empty states"); + let states = + ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states"); let app = wireguard_admin::app::Application::open(fixture.store.clone(), states) .expect("application opens on synthetic files"); ApplicationWorker::spawn( @@ -166,7 +166,7 @@ fn terminal_pty_smoke() { const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD"; if std::env::var_os(CHILD).is_some() { let fixture = fixture(); - let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) + let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown) .expect("empty states"); let app = wireguard_admin::app::Application::open(fixture.store, states) .expect("synthetic application opens"); @@ -813,10 +813,7 @@ fn worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operation match wait_for(&mut worker, restore.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!(matches!(result.unwrap(), CommandResult::Restored(_))); - assert_eq!( - view.applied, - wireguard_admin::tui::state::AppliedState::Unknown - ); + assert_eq!(view.applied, wireguard_admin::domain::ApplyStatus::Unknown); assert!(!view.has_draft); } _ => unreachable!(), @@ -916,8 +913,8 @@ fn worker_commit_stops_at_mandatory_backup_failure_and_keeps_draft_and_saved_byt #[test] fn worker_restore_refreshes_operational_allocation_settings_before_next_create() { let fixture = fixture(); - let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::Unknown) - .expect("empty states"); + let states = + ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states"); let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states) .expect("original application opens"); original_app