From 805a9b4eaafce6ea03081f3f7fcc5c000d6a4fb2 Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 14:52:33 +0500 Subject: [PATCH 1/6] feat(format): short_addr returns for display lists only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 backlog item: first 6 + last 4 hex, EIP-55 casing verbatim, non-ASCII/short/unprefixed input returned as-is. For the Activity list rows exclusively — signing surfaces keep full addresses (ТЗ §4.1). --- src/format.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/format.rs b/src/format.rs index 466da17..bba9088 100644 --- a/src/format.rs +++ b/src/format.rs @@ -54,6 +54,22 @@ pub fn is_zero_wei(wei: &str) -> bool { !digits.is_empty() && digits.bytes().all(|b| b == b'0') } +/// Shorten an address for a DISPLAY-LIST row: `0x489Fe0…bbbb` (first 6 + last 4 +/// hex digits, EIP-55 casing preserved verbatim). **Never on a signing or +/// approving surface** — the card, From→To and Receive render addresses in full +/// (address poisoning; ТЗ §4.1): this exists for the Activity list only, where +/// the row is display, not a decision. Input without a `0x` prefix, non-ASCII, +/// or too short to save space is returned verbatim (display never crashes). +#[must_use] +pub fn short_addr(addr: &str) -> String { + match addr.strip_prefix("0x") { + Some(hex) if hex.is_ascii() && hex.len() > 12 => { + format!("0x{}…{}", &hex[..6], &hex[hex.len() - 4..]) + } + _ => addr.to_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -94,4 +110,26 @@ mod tests { assert!(!is_zero_wei("10000000000000000")); assert!(!is_zero_wei("")); } + + #[test] + fn short_addr_keeps_head_tail_and_eip55_casing() { + assert_eq!( + short_addr("0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb"), + "0x489Fe0…bbbb", + "first 6 + last 4, casing verbatim" + ); + } + + #[test] + fn short_addr_returns_unshortenable_input_verbatim() { + assert_eq!(short_addr("0x1234567890ab"), "0x1234567890ab", "12 hex: nothing saved"); + assert_eq!(short_addr("not-an-address"), "not-an-address", "no 0x prefix"); + assert_eq!(short_addr(""), ""); + } + + #[test] + fn short_addr_never_panics_on_non_ascii() { + let hostile = "0xдлинная-не-ascii-строка-длиннее-двенадцати"; + assert_eq!(short_addr(hostile), hostile, "non-ASCII input is returned verbatim"); + } } From dc99d99c0be73676e4933a08075663eebd9c65b4 Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 14:52:33 +0500 Subject: [PATCH 2/6] =?UTF-8?q?feat(protocol):=20activity=20op=20client=20?= =?UTF-8?q?side=20=E2=80=94=20Request::Activity=20+=20parse=5Factivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutcomeState is deliberately not TerminalState: §3.9 promises never 'pending', so a reply carrying one fails the parse (default-deny). OutcomeEntry mirrors §3.9 verbatim (tx_hash/reason absent => None); parse_activity mirrors parse_positions but has NO degradable answer — wallet_locked is not in the §3.9 vocabulary, any error fails closed. --- src/protocol.rs | 151 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/protocol.rs b/src/protocol.rs index 315d4ee..c936de6 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -68,6 +68,10 @@ pub enum Request<'a> { /// protocol §3.8). Dispatched by the read-op scheduler only right after /// a `list` reply, never ahead of one (the §3.8 client rule). Positions, + /// Ask for the recent terminal outcomes (proto 2+, auth-gated — protocol + /// §3.9): newest first, server-capped at 100. Same scheduler discipline + /// as [`Request::Positions`] — only right after a `list` reply. + Activity, } /// Serialize a request to a single JSON line (no trailing `\n`; the transport adds @@ -304,6 +308,45 @@ pub enum PositionsOutcome { WalletLocked, } +/// The four terminal words an `activity` outcome may carry (protocol §3.9). +/// Deliberately NOT [`TerminalState`]: that enum includes `Pending` for +/// `already_resolved` (§3.5), which §3.9 promises never to send — a reply +/// carrying `"pending"` here must FAIL the parse (default-deny), not slip +/// through. `Serialize` is derived so the console's local activity log stores +/// exactly these protocol words (one vocabulary end to end). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeState { + /// Signed and broadcast. + Executed, + /// Rejected by the human. + Denied, + /// Expired before a decision. + Expired, + /// Approved, but signing/broadcast failed. + Failed, +} + +/// One retained terminal outcome from `activity` (protocol §3.9), verbatim. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct OutcomeEntry { + /// The resolved item's preview id — stable across polls; the dedup key + /// for the console's local log (§3.9). + pub id: String, + /// Terminal state word (§3.9 — never `"pending"`; see [`OutcomeState`]). + pub state: OutcomeState, + /// The executed transaction's hash (`0x…`) — executed only; on every + /// other state the field is absent on the wire (§3.9) and `None` here. + #[serde(default)] + pub tx_hash: Option, + /// Operator-masked failure reason — failed only; absent otherwise. + #[serde(default)] + pub reason: Option, + /// Seconds since the resolution — a relative age, not a timestamp; the + /// console derives an absolute time locally at arrival (§3.9). + pub age_secs: u64, +} + /// The terminal state carried by an `already_resolved` reply (protocol §3.5). /// Includes `Pending` (I4): another connection is executing this id right now. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -573,6 +616,35 @@ pub fn parse_positions(line: &str) -> Result { } } +/// Parse an `activity` reply (§3.9). Unlike `context`/`positions` there is NO +/// degradable answer: the op reads only the outcome store — `wallet_locked` +/// is not in its vocabulary (§3.9). We only send `activity` post-auth on a +/// proto-2 session, so `unauthorized`/`protocol_error` (or any other code) +/// mean the channel is not what we negotiated — the same fail-closed class +/// as an unexpected resolve code. +/// +/// # Errors +/// [`ProtocolError::Malformed`] on a wrong shape (including a `"pending"` +/// state — see [`OutcomeState`]); [`ProtocolError::Unexpected`] on `ok:false`. +pub fn parse_activity(line: &str) -> Result, ProtocolError> { + #[derive(Deserialize)] + struct Raw { + ok: bool, + outcomes: Option>, + error: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + raw.outcomes.ok_or_else(|| { + ProtocolError::Malformed("ok activity without an outcomes array".to_owned()) + }) + } else { + Err(ProtocolError::Unexpected(raw.error.unwrap_or_else(|| { + "activity without ok or error".to_owned() + }))) + } +} + /// The fields an `approve` / `deny` reply may carry. `state` means the outcome /// (`executed`/`failed`/`denied`) on an `ok` reply, or the `already_resolved` /// state on an error reply. @@ -1165,4 +1237,83 @@ mod tests { r#"{"op":"positions"}"# ); } + + // ── activity (§3.9, Stage 7) — mirrors the positions parse suite ── + + #[test] + fn parse_activity_carries_all_four_states_verbatim() { + let line = r#"{"ok":true,"outcomes":[ + {"id":"e1","state":"executed","tx_hash":"0xfeed","age_secs":42}, + {"id":"d1","state":"denied","age_secs":120}, + {"id":"x1","state":"expired","age_secs":1800}, + {"id":"f1","state":"failed","reason":"broadcast failed","age_secs":3599}]}"# + .replace('\n', ""); + let outcomes = parse_activity(&line).unwrap(); + assert_eq!(outcomes.len(), 4); + assert_eq!(outcomes[0].id, "e1"); + assert_eq!(outcomes[0].state, OutcomeState::Executed); + assert_eq!(outcomes[0].tx_hash.as_deref(), Some("0xfeed")); + assert_eq!(outcomes[0].reason, None); + assert_eq!(outcomes[0].age_secs, 42); + assert_eq!(outcomes[1].state, OutcomeState::Denied); + assert_eq!( + (outcomes[1].tx_hash.as_deref(), outcomes[1].reason.as_deref()), + (None, None), + "absent wire fields read as None, never a fabricated value" + ); + assert_eq!(outcomes[2].state, OutcomeState::Expired); + assert_eq!(outcomes[3].state, OutcomeState::Failed); + assert_eq!(outcomes[3].reason.as_deref(), Some("broadcast failed")); + assert_eq!(outcomes[3].tx_hash, None); + } + + #[test] + fn parse_activity_accepts_an_empty_history() { + let line = r#"{"ok":true,"outcomes":[]}"#; + assert_eq!(parse_activity(line).unwrap(), vec![]); + } + + #[test] + fn parse_activity_never_accepts_a_pending_state() { + // §3.9: only terminal words; "pending" is not in the vocabulary and + // must fail the parse (default-deny), not slip through as data. + let line = r#"{"ok":true,"outcomes":[{"id":"p1","state":"pending","age_secs":1}]}"#; + assert!(matches!( + parse_activity(line), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn parse_activity_unexpected_errors_fail_closed() { + // §3.9 has no degradable answer (no wallet_locked): any error code + // means the channel is not what we negotiated. + for code in ["unauthorized", "protocol_error", "wallet_locked"] { + let line = format!(r#"{{"ok":false,"error":"{code}"}}"#); + assert!(matches!( + parse_activity(&line), + Err(ProtocolError::Unexpected(_)) + )); + } + } + + #[test] + fn parse_activity_ok_without_array_is_malformed() { + assert!(matches!( + parse_activity(r#"{"ok":true}"#), + Err(ProtocolError::Malformed(_)) + )); + assert!(matches!( + parse_activity("not json"), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn activity_request_encodes_the_documented_op() { + assert_eq!( + encode_request(&Request::Activity).unwrap(), + r#"{"op":"activity"}"# + ); + } } From 6682dab7eb44657af638634b01bfcd309d449658 Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 15:12:25 +0500 Subject: [PATCH 3/6] feat(console): Activity view + local outcome log (Stage 7, closes Phase 2 read layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transport/app: Request::Activity + Reply::Activity; the activity op rides the existing list-first slot when the Activity view is on top (same STALE_TICKS cadence; the Dashboard's two-valued alternation is untouched and resumes where it left off). - History pipeline: the model owns a pure Vec (unix desc, id asc — ties inside one second are id-ordered by documented design); the run loop owns the clock, the JSONL file on RUSTOK_CONSOLE_LOG -> $RUSTOK_DATA_DIR/console-activity.jsonl, and the id-dedup set. A decision born here is recorded rich (to/amount from the still-open card in apply_resolve); server outcomes stamp arrival-age (§3.9) and fill missing fields of known rows (never overwrite, file keeps its first record). Corrupt lines skip with a visible note; a failed write switches to session-only with a visible note; oversized files compact atomically. - ui: fourth tab (h), filter cycle (f), shortened addresses — the one display list ТЗ §4.1 allows; exact-fit/'+N more' budget (Stage-5 lesson). - Decision/decision_line (ADR #7) untouched — the history drain is a separate channel, proven by test. --- src/app.rs | 558 +++++++++++++++++++++++++++++++++++++++++++++-- src/format.rs | 18 +- src/main.rs | 384 +++++++++++++++++++++++++++++++- src/protocol.rs | 5 +- src/transport.rs | 18 +- src/ui.rs | 273 ++++++++++++++++++++++- 6 files changed, 1228 insertions(+), 28 deletions(-) diff --git a/src/app.rs b/src/app.rs index 27fdff3..79acb79 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,11 +12,12 @@ //! explicit `y` approves; `n`, Esc, Ctrl-C and the expiry deadline all send `deny`. //! Leaving the confirmation without deciding is not offered. +use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::protocol::{ - AuthOutcome, Card, ContextOutcome, GetOutcome, PositionsOutcome, ResolveOutcome, Summary, - TerminalState, WalletContext, + AuthOutcome, Card, ContextOutcome, GetOutcome, OutcomeEntry, OutcomeState, PositionsOutcome, + ResolveOutcome, Summary, TerminalState, WalletContext, }; use crate::transport::{self, Reply, TransportError}; use crate::ui; @@ -178,6 +179,11 @@ pub enum View { /// The wallet's own address + QR. Pure display: signs nothing, sends /// nothing, adds no socket op. Receive, + /// Terminal outcomes — the local log merged with the server's retained + /// window (Stage 7). Pure display with a state filter; addresses here + /// are SHORTENED (`format::short_addr`) — a display list, not a signing + /// surface (ТЗ §4.1). + Activity, } /// The dashboard's positions block — a tri-state, so "still loading" never @@ -265,6 +271,129 @@ pub struct Decision { pub reason: Option, } +/// One row of the console's activity history — the local log's stored form +/// (one JSONL line) and the Activity view's display form, one representation +/// end to end. `to`/`amount_wei`/`chain_id` are `None` on a server-only +/// record: the retained window drops the preview on resolve, so a record +/// first seen via `activity` is honestly poorer than one written at decision +/// time. Unknown fields are ignored on load (soft evolution, no version field). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoryEntry { + /// Resolution time, unix seconds — stamped by the run loop (the model has + /// no clock): `now` for a decision made here, `arrival − age_secs` for a + /// server outcome (§3.9). + pub unix: u64, + /// The item's preview id — the dedup key (§3.9). + pub id: String, + /// Terminal state, protocol vocabulary (§3.5/§3.9 words, one end to end). + pub state: OutcomeState, + /// Recipient (EIP-55, stored verbatim) — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + /// Native value, decimal wei string — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount_wei: Option, + /// Chain id — rich records only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + /// Executed transaction hash, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, + /// Operator-masked failure reason, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// A decision made on THIS console, drained by the run loop +/// ([`Model::take_decided_outcome`]) to be stamped (unix = now), appended to +/// the local log, and pushed back via [`Model::push_history`]. The timestamp +/// is deliberately absent — the model has no clock (Stage-2 lesson). Born in +/// `apply_resolve` while the card is still open: the ONLY moment to/amount +/// are known (the server's retained outcome drops the preview). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecidedOutcome { + /// The item's preview id. + pub id: String, + /// Terminal state, protocol vocabulary. + pub state: OutcomeState, + /// Recipient, EIP-55 verbatim (from the open card). + pub to: String, + /// Native value, decimal wei string (from the open card). + pub amount_wei: String, + /// Chain id (from the open card). + pub chain_id: u64, + /// Executed transaction hash, when this console saw it. + pub tx_hash: Option, + /// Failure reason, when this console saw it. + pub reason: Option, +} + +/// The Activity view's outcome filter, cycled by `f`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HistoryFilter { + /// Every outcome. + #[default] + All, + /// Executed only. + Executed, + /// Denied only. + Denied, + /// Expired only. + Expired, + /// Failed only. + Failed, +} + +impl HistoryFilter { + /// The next filter in the `f`-cycle. + #[must_use] + pub fn next(self) -> Self { + match self { + Self::All => Self::Executed, + Self::Executed => Self::Denied, + Self::Denied => Self::Expired, + Self::Expired => Self::Failed, + Self::Failed => Self::All, + } + } + + /// Whether an entry in this state passes the filter. + #[must_use] + pub fn admits(self, state: OutcomeState) -> bool { + match self { + Self::All => true, + Self::Executed => state == OutcomeState::Executed, + Self::Denied => state == OutcomeState::Denied, + Self::Expired => state == OutcomeState::Expired, + Self::Failed => state == OutcomeState::Failed, + } + } + + /// The view-header label. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::All => "all", + Self::Executed => "executed", + Self::Denied => "denied", + Self::Expired => "expired", + Self::Failed => "failed", + } + } +} + +/// The protocol word for a decision made here — the ONE mapping between +/// [`DecisionKind`] and the stored/wire vocabulary (a named seam: a swapped +/// arm must fail a test, not pass every gate green). +fn outcome_state(kind: DecisionKind) -> OutcomeState { + match kind { + DecisionKind::Approved => OutcomeState::Executed, + DecisionKind::Rejected => OutcomeState::Denied, + DecisionKind::Expired => OutcomeState::Expired, + DecisionKind::Failed => OutcomeState::Failed, + } +} + /// An open card **is** the confirmation dialog (`AGENTS.md` #5): it is left by /// approving or denying, never by simply closing. #[derive(Debug)] @@ -394,6 +523,10 @@ pub enum Msg { /// Switch to a view (nav-shell tab). Refused while a card is open or a /// card request exists — see [`Model::on_view`]. View(View), + /// Cycle the Activity view's outcome filter (`f`). A no-op outside the + /// Activity view — the key is only mapped there, but the model is the + /// boundary, not the key map. + Filter, /// Quit. Quit, } @@ -433,8 +566,28 @@ pub struct Model { /// block is flagged stale (Gate-1: named mechanism, not derived from /// `read_age`). Cleared by a successful refresh. context_stale: bool, + /// Activity history, newest first (unix desc, id asc) — fed ONLY by the + /// run loop (log load + stamped pushes); the model never stamps a time + /// (it has no clock). Capped at [`HISTORY_CAP`]. + history: Vec, + /// The Activity view's outcome filter (`f` cycles it). + history_filter: HistoryFilter, + /// A decision made here, waiting for the run loop to stamp and persist + /// it ([`Self::take_decided_outcome`]). + decided: Option, + /// Server outcomes from `activity` replies, waiting for the run loop's + /// dedup/stamp pass ([`Self::take_server_outcomes`]). + server_outcomes: Vec, + /// A static log-degradation note for the Activity footer, set once at + /// startup by the run loop (persistence off / log partially unreadable). + /// `None` when the log is healthy. + history_note: Option, } +/// In-memory/display cap on the activity history (the file compacts at 2× +/// this on load — run-loop side, which is why it is `pub`). +pub const HISTORY_CAP: usize = 500; + /// A parked user intent (no `List` — the poll is suppressed, not queued). /// /// **Not `derive(Debug)`**: `ApprovePin` holds the serialized PIN, and `Zeroizing` @@ -478,6 +631,11 @@ impl Default for Model { read_age: STALE_TICKS, next_read: ReadOp::default(), context_stale: false, + history: Vec::new(), + history_filter: HistoryFilter::default(), + decided: None, + server_outcomes: Vec::new(), + history_note: None, } } } @@ -535,6 +693,102 @@ impl Model { self.decision.take() } + /// The activity history, newest first (unix desc, id asc — display order). + #[must_use] + pub fn history(&self) -> &[HistoryEntry] { + &self.history + } + + /// The Activity view's current outcome filter. + #[must_use] + pub fn history_filter(&self) -> HistoryFilter { + self.history_filter + } + + /// The static log-degradation note for the Activity footer, if any. + #[must_use] + pub fn history_note(&self) -> Option<&str> { + self.history_note.as_deref() + } + + /// Set the log-degradation footer note (run-loop startup, at most once). + pub fn set_history_note(&mut self, note: String) { + self.history_note = Some(note); + } + + /// Seed the history from the local log (run-loop startup; the caller + /// dedups by id — it owns the dedup set). Sorted and capped here so the + /// display invariant never depends on file order. + pub fn set_history(&mut self, entries: Vec) { + self.history = entries; + self.sort_and_cap_history(); + } + + /// Add one stamped entry (run loop only; id uniqueness is the caller's + /// contract — it owns the dedup set). + pub fn push_history(&mut self, entry: HistoryEntry) { + self.history.push(entry); + self.sort_and_cap_history(); + } + + /// Fill MISSING detail on a known entry (spec /check-2), both ways: an + /// item resolved by another connection leaves our rich record without + /// tx_hash/reason (the server window carries them); a server-only record + /// gains to/amount/chain when the same id is later decided here. Present + /// fields are never overwritten; the file keeps its first record + /// (append-only) — this enriches the session view only. + pub fn fill_history_detail( + &mut self, + id: &str, + to: Option<&str>, + amount_wei: Option<&str>, + chain_id: Option, + tx_hash: Option<&str>, + reason: Option<&str>, + ) { + let Some(entry) = self.history.iter_mut().find(|e| e.id == id) else { + return; + }; + if entry.to.is_none() { + entry.to = to.map(str::to_owned); + } + if entry.amount_wei.is_none() { + entry.amount_wei = amount_wei.map(str::to_owned); + } + if entry.chain_id.is_none() { + entry.chain_id = chain_id; + } + if entry.tx_hash.is_none() { + entry.tx_hash = tx_hash.map(str::to_owned); + } + if entry.reason.is_none() { + entry.reason = reason.map(str::to_owned); + } + } + + /// Drain the decision made here, for the run loop to stamp (unix = now), + /// persist, and push back via [`Self::push_history`] — in the same loop + /// pass, before the next render (the order is normative). + pub fn take_decided_outcome(&mut self) -> Option { + self.decided.take() + } + + /// Drain the server outcomes queued by `activity` replies, for the run + /// loop's dedup/stamp pass (unix = arrival − age, §3.9). + pub fn take_server_outcomes(&mut self) -> Vec { + std::mem::take(&mut self.server_outcomes) + } + + /// Newest first: unix desc, id asc on ties (a lockout batch shares one + /// second — determinism mirrors the server's own tiebreak). Two RICH + /// decisions inside one second tie-break by id too — a documented + /// consequence of whole-second stamps, not chronology (Gate-1 finding). + fn sort_and_cap_history(&mut self) { + self.history + .sort_unstable_by(|a, b| b.unix.cmp(&a.unix).then_with(|| a.id.cmp(&b.id))); + self.history.truncate(HISTORY_CAP); + } + /// Fold one message into the model, returning at most one request to send. pub fn update(&mut self, msg: Msg) -> Option { match msg { @@ -583,6 +837,17 @@ impl Model { self.on_view(view); None } + Msg::Filter => { + if let Phase::Watching { + view: View::Activity, + confirm: None, + .. + } = &self.phase + { + self.history_filter = self.history_filter.next(); + } + None + } Msg::Open => self.on_open(), } } @@ -607,8 +872,8 @@ impl Model { } = &mut self.phase { *current = view; - if view == View::Dashboard { - // Entering the dashboard marks the read data stale: the next + if matches!(view, View::Dashboard | View::Activity) { + // Entering a read-op view marks the read data stale: the next // list reply refreshes it. Bounded structurally — at most one // read-op per list reply (spec /check-5). self.read_age = self.read_age.max(STALE_TICKS); @@ -861,6 +1126,7 @@ impl Model { } Reply::Context(outcome) => self.apply_context(outcome), Reply::Positions(outcome) => self.apply_positions(outcome), + Reply::Activity(outcomes) => self.apply_activity(outcomes), Reply::List(items) => self.apply_list(items), Reply::Get(outcome) => self.apply_get(outcome), Reply::Resolve(outcome) => self.apply_resolve(outcome), @@ -887,11 +1153,15 @@ impl Model { } /// The read-op scheduler (list-first policy, canon §3.8 + the Reviewer's - /// Gate-1 flag): a read-op (`positions` | `context` refresh, alternating) - /// goes out ONLY right after a fresh `list` reply — never from a tick - /// (the tick belongs to `list`), never ahead of a parked user intent - /// (`flush_pending` ran first), never with a card open or a card request - /// anywhere in flight, and only when the read data has gone stale. + /// Gate-1 flag): a read-op goes out ONLY right after a fresh `list` + /// reply — never from a tick (the tick belongs to `list`), never ahead + /// of a parked user intent (`flush_pending` ran first), never with a + /// card open or a card request anywhere in flight, and only when the + /// read data has gone stale. WHICH op is the view's choice: the + /// Dashboard alternates `positions`/`context` (the two-valued + /// [`ReadOp`] state is untouched by Stage 7); the Activity view sends + /// `activity` — it never joins the alternation, so leaving and + /// re-entering the Dashboard resumes exactly where it left off. /// /// Guaranteed and tested: the ORDER OF ORIGINATION — a read-op never cuts /// ahead of a due `list`. Honest limit (Gate-1): once a read-op is on the @@ -899,16 +1169,14 @@ impl Model { /// list polling stalls with it — the same risk class as the post-auth /// `context` fetch accepted since Stage 2, occurring more often here. fn dispatch_read_op(&mut self) -> Option { - if !matches!( - self.phase, + let view = match &self.phase { Phase::Watching { confirm: None, - view: View::Dashboard, + view: view @ (View::Dashboard | View::Activity), .. - } - ) { - return None; - } + } => *view, + _ => return None, + }; // `pending` is None here (flush_pending ran first and found nothing) // and `awaiting_card` cannot coexist with a just-answered list on the // single slot — both checked anyway: the model is the boundary. @@ -917,6 +1185,9 @@ impl Model { } self.read_age = 0; // the cadence counts from dispatch self.in_flight = true; + if view == View::Activity { + return Some(transport::Request::Activity); + } let req = match self.next_read { ReadOp::Positions => transport::Request::Positions, ReadOp::Context => transport::Request::Context, @@ -928,6 +1199,14 @@ impl Model { Some(req) } + /// Fold an `activity` answer: queue the server outcomes for the run + /// loop's dedup/stamp pass ([`Self::take_server_outcomes`]). Deliberately + /// view-independent — history is global state, an in-flight answer + /// landing after a tab switch still counts. + fn apply_activity(&mut self, outcomes: Vec) { + self.server_outcomes.extend(outcomes); + } + /// Fold a `positions` answer — feeds the dashboard block only, gates /// nothing. Deliberately simpler than [`Self::apply_context`]: a /// `wallet_locked` here REPLACES `Loaded` with `Unavailable` instead of @@ -1071,13 +1350,28 @@ impl Model { let detail = tx_hash.clone().or_else(|| reason.clone()); self.decision = Some(Decision { kind, - tx_hash, - reason, + tx_hash: tx_hash.clone(), + reason: reason.clone(), }); if let Phase::Watching { confirm, notice, .. } = &mut self.phase { + if let Some(c) = confirm.as_deref() { + // The card is still open — the ONLY moment to/amount are + // known (the server's retained outcome drops the preview): + // the rich history record is born here, stamped and + // persisted by the run loop (Stage 7). + self.decided = Some(DecidedOutcome { + id: c.card.id.clone(), + state: outcome_state(kind), + to: c.card.to.clone(), + amount_wei: c.card.amount_wei.clone(), + chain_id: c.card.chain_id, + tx_hash, + reason, + }); + } *confirm = None; *notice = Some(Notice::Outcome { kind, detail }); } @@ -2925,4 +3219,232 @@ mod tests { m.update(Msg::View(View::Dashboard)); assert_eq!(view_of(&m), View::Queue, "the card pins the queue view"); } + + // ── Stage 7: the Activity view, its scheduler slot, the history model ── + + fn history_entry(id: &str, unix: u64, state: OutcomeState) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + #[test] + fn the_activity_view_dispatches_the_activity_read_op() { + let mut m = on_dashboard(); + m.update(Msg::View(View::Activity)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Activity)), + "on the Activity view the list-first slot carries the activity op" + ); + } + + #[test] + fn the_tick_belongs_to_list_on_the_activity_view_too() { + let mut m = on_dashboard(); + m.update(Msg::View(View::Activity)); + assert!( + matches!(m.update(Msg::Tick), Some(transport::Request::List)), + "a tick never births a read-op — the activity op waits for the list reply" + ); + } + + #[test] + fn entering_activity_marks_the_read_data_stale() { + let mut m = on_dashboard(); + // Consume the born-stale dispatch and hand its answer back: fresh data. + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + assert!(poll_round(&mut m).is_none(), "fresh data: no read-op due"); + m.update(Msg::View(View::Activity)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Activity)), + "entering the Activity view marks the data stale — the next list reply fetches" + ); + } + + #[test] + fn activity_never_joins_the_dashboard_alternation() { + let mut m = on_dashboard(); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + // Visit Activity: its op rides the slot without touching next_read. + m.update(Msg::View(View::Activity)); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Activity) + )); + m.update(Msg::Reply(Reply::Activity(vec![]))); + // Back on the Dashboard the alternation resumes exactly where it left. + m.update(Msg::View(View::Dashboard)); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Context)), + "the two-valued alternation is untouched by the activity slot" + ); + } + + #[test] + fn a_decision_here_births_a_rich_history_record() { + let mut m = watching(vec![summary("a1")]); + assert!(matches!( + m.update(Msg::Open), + Some(transport::Request::Get(_)) + )); + m.update(Msg::Reply(Reply::Get(GetOutcome::Card(card("a1"))))); + assert!(matches!( + m.update(Msg::Approve), + Some(transport::Request::Approve(_)) + )); + m.update(Msg::Reply(Reply::Resolve(ResolveOutcome::Executed { + tx_hash: "0xfeed".to_owned(), + }))); + + let d = m + .take_decided_outcome() + .expect("a rich record is born at decision time, while the card is open"); + assert_eq!(d.id, "a1"); + assert_eq!(d.state, OutcomeState::Executed); + assert_eq!(d.to, "0xabc", "the card's recipient, verbatim"); + assert_eq!(d.amount_wei, "0", "the card's amount"); + assert_eq!(d.chain_id, 1); + assert_eq!(d.tx_hash.as_deref(), Some("0xfeed")); + assert_eq!(d.reason, None); + assert!(m.take_decided_outcome().is_none(), "drained exactly once"); + // The ADR-#7 decision-line drain is a separate, untouched channel. + assert!(m.take_decision().is_some()); + } + + #[test] + fn server_outcomes_queue_for_the_run_loop_and_drain_once() { + let mut m = on_dashboard(); + m.update(Msg::Reply(Reply::Activity(vec![OutcomeEntry { + id: "s1".to_owned(), + state: OutcomeState::Denied, + tx_hash: None, + reason: None, + age_secs: 42, + }]))); + let drained = m.take_server_outcomes(); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].id, "s1"); + assert!( + m.take_server_outcomes().is_empty(), + "the queue drains exactly once" + ); + } + + #[test] + fn history_sorts_newest_first_ties_by_id_and_caps() { + let mut m = Model::new(); + m.set_history(vec![ + history_entry("b", 100, OutcomeState::Denied), + history_entry("c", 200, OutcomeState::Executed), + history_entry("a", 100, OutcomeState::Denied), + ]); + let ids: Vec<&str> = m.history().iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + vec!["c", "a", "b"], + "newest first; a shared second falls back to id order" + ); + + let many: Vec = (0..=HISTORY_CAP) + .map(|i| history_entry(&format!("id{i:04}"), i as u64, OutcomeState::Denied)) + .collect(); + m.set_history(many); + assert_eq!( + m.history().len(), + HISTORY_CAP, + "capped at the display limit" + ); + assert!( + m.history().iter().all(|e| e.unix >= 1), + "what fell off the cap is exactly the oldest entry" + ); + } + + #[test] + fn two_rich_decisions_in_one_second_order_by_id_not_chronology() { + // Gate-1 finding, documented on purpose: unix is whole seconds — two + // decisions inside one second CANNOT be ordered by time here; the id + // tiebreak (asc) makes the order deterministic, not chronological. + let mut ids = [uuid_like("f"), uuid_like("0")]; + ids.sort(); + let (small, big) = (ids[0].clone(), ids[1].clone()); + let mut m = Model::new(); + // Decided big-id first, small-id second — same second. + m.push_history(history_entry(&big, 77, OutcomeState::Denied)); + m.push_history(history_entry(&small, 77, OutcomeState::Denied)); + let got: Vec<&str> = m.history().iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + got, + vec![small.as_str(), big.as_str()], + "id order wins inside one second — regardless of decision order" + ); + } + + fn uuid_like(prefix: &str) -> String { + format!("{prefix}1b2c3d-0000-0000-0000-000000000000") + } + + #[test] + fn fill_history_detail_fills_only_the_missing_fields() { + let mut m = Model::new(); + let mut rich = history_entry("a", 10, OutcomeState::Executed); + rich.to = Some("0xTO".to_owned()); + m.set_history(vec![rich]); + + m.fill_history_detail( + "a", + Some("0xEVIL"), + Some("5"), + Some(1), + Some("0xfeed"), + None, + ); + let e = &m.history()[0]; + assert_eq!( + e.to.as_deref(), + Some("0xTO"), + "a present field is never overwritten" + ); + assert_eq!(e.amount_wei.as_deref(), Some("5"), "a missing field fills"); + assert_eq!(e.chain_id, Some(1)); + assert_eq!(e.tx_hash.as_deref(), Some("0xfeed")); + assert_eq!(e.reason, None); + } + + #[test] + fn the_filter_cycles_only_on_the_activity_view() { + let mut m = on_dashboard(); + m.update(Msg::Filter); + assert_eq!( + m.history_filter(), + HistoryFilter::All, + "no cycling off the Activity view — the model is the boundary" + ); + m.update(Msg::View(View::Activity)); + m.update(Msg::Filter); + assert_eq!(m.history_filter(), HistoryFilter::Executed); + for _ in 0..4 { + m.update(Msg::Filter); + } + assert_eq!( + m.history_filter(), + HistoryFilter::All, + "the cycle closes back on All" + ); + } } diff --git a/src/format.rs b/src/format.rs index bba9088..3fffb09 100644 --- a/src/format.rs +++ b/src/format.rs @@ -122,14 +122,26 @@ mod tests { #[test] fn short_addr_returns_unshortenable_input_verbatim() { - assert_eq!(short_addr("0x1234567890ab"), "0x1234567890ab", "12 hex: nothing saved"); - assert_eq!(short_addr("not-an-address"), "not-an-address", "no 0x prefix"); + assert_eq!( + short_addr("0x1234567890ab"), + "0x1234567890ab", + "12 hex: nothing saved" + ); + assert_eq!( + short_addr("not-an-address"), + "not-an-address", + "no 0x prefix" + ); assert_eq!(short_addr(""), ""); } #[test] fn short_addr_never_panics_on_non_ascii() { let hostile = "0xдлинная-не-ascii-строка-длиннее-двенадцати"; - assert_eq!(short_addr(hostile), hostile, "non-ASCII input is returned verbatim"); + assert_eq!( + short_addr(hostile), + hostile, + "non-ASCII input is returned verbatim" + ); } } diff --git a/src/main.rs b/src/main.rs index e332618..7d56651 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,9 @@ //! `2026-07-12-invariant-7-decision-stream`); the exit code reports how the //! session ended (aborted / no-tty / fatal / upgrade). +use std::collections::{HashMap, HashSet}; use std::io::{self, IsTerminal, Stderr, Write as _}; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -23,7 +25,9 @@ use ratatui::crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; -use rustok_console::app::{Decision, DecisionKind, Model, Msg, Phase, View}; +use rustok_console::app::{ + Decision, DecisionKind, HISTORY_CAP, HistoryEntry, Model, Msg, Phase, View, +}; use rustok_console::transport::{Transport, TransportError}; use rustok_console::ui; @@ -112,12 +116,164 @@ fn set_panic_hook() { })); } +/// The run loop's side of the history pipeline (Stage 7): the model has no +/// clock and no filesystem, so stamping, persistence and the id-dedup set +/// (§3.9) live here. +struct HistoryLog { + /// Append target; `None` = session-only (no path configured, or a write + /// failed and persistence was honestly switched off). + path: Option, + /// Every id in the log or recorded this session — the dedup set. + seen: HashSet, +} + +impl HistoryLog { + /// Resolve the path cascade (`RUSTOK_CONSOLE_LOG` → + /// `$RUSTOK_DATA_DIR/console-activity.jsonl` → session-only), load and + /// dedup the file, compact it when oversized (best-effort), and seed the + /// model with the entries and a degradation note when there is one. + fn open(model: &mut Model) -> Self { + let Some(path) = history_log_path() else { + model.set_history_note( + "history is session-only — set RUSTOK_CONSOLE_LOG or RUSTOK_DATA_DIR to persist" + .to_owned(), + ); + return Self { + path: None, + seen: HashSet::new(), + }; + }; + let (entries, skipped) = load_history(&path); + if skipped > 0 { + model.set_history_note(format!( + "activity log partially unreadable — {skipped} line(s) skipped" + )); + } + if entries.len() > 2 * HISTORY_CAP { + compact_history(&path, &entries); + } + let seen = entries.iter().map(|e| e.id.clone()).collect(); + model.set_history(entries); + Self { + path: Some(path), + seen, + } + } + + /// Record one stamped entry: a NEW id is appended to the file (when + /// persisting) and pushed into the model; a known id only fills the + /// missing fields of its existing row (fill-missing merge, spec /check-2 + /// — the file keeps its first record, append-only). A failed write + /// switches persistence off with a visible note — never a crash, never + /// silence. + fn record(&mut self, model: &mut Model, entry: HistoryEntry) { + if !self.seen.insert(entry.id.clone()) { + model.fill_history_detail( + &entry.id, + entry.to.as_deref(), + entry.amount_wei.as_deref(), + entry.chain_id, + entry.tx_hash.as_deref(), + entry.reason.as_deref(), + ); + return; + } + if let Some(path) = &self.path + && append_history_line(path, &entry).is_err() + { + self.path = None; + model.set_history_note( + "history is not being persisted — the activity log write failed".to_owned(), + ); + } + model.push_history(entry); + } +} + +/// The local log's path cascade: an explicit override, else the wallet +/// image's persistent volume (`RUSTOK_DATA_DIR=/data`, Dockerfile.wallet), +/// else nowhere (session-only). +fn history_log_path() -> Option { + if let Ok(explicit) = std::env::var("RUSTOK_CONSOLE_LOG") { + return Some(PathBuf::from(explicit)); + } + std::env::var("RUSTOK_DATA_DIR") + .ok() + .map(|dir| Path::new(&dir).join("console-activity.jsonl")) +} + +/// Load the JSONL log: corrupt lines are skipped (counted, never fatal), and +/// entries are deduped by id — two consoles may have appended the same server +/// outcome concurrently (spec /check-3); the newest stamp wins. A missing +/// file is an empty history, not an error. +fn load_history(path: &Path) -> (Vec, usize) { + let Ok(text) = std::fs::read_to_string(path) else { + return (Vec::new(), 0); + }; + let mut skipped = 0usize; + let mut by_id: HashMap = HashMap::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(entry) => match by_id.entry(entry.id.clone()) { + std::collections::hash_map::Entry::Occupied(mut slot) => { + if entry.unix > slot.get().unix { + slot.insert(entry); + } + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entry); + } + }, + Err(_) => skipped += 1, + } + } + (by_id.into_values().collect(), skipped) +} + +/// Append one entry as a JSONL line. +fn append_history_line(path: &Path, entry: &HistoryEntry) -> io::Result<()> { + let mut line = + serde_json::to_string(entry).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + line.push('\n'); + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)? + .write_all(line.as_bytes()) +} + +/// Best-effort compaction: rewrite the newest [`HISTORY_CAP`] entries +/// atomically (tmp beside the file + rename, same filesystem) — corrupt and +/// duplicate lines are dropped with the rest (the file heals itself). Called +/// only when the loaded file carried more than 2× the cap; any failure +/// leaves the file exactly as it was. +fn compact_history(path: &Path, entries: &[HistoryEntry]) { + let mut newest: Vec<&HistoryEntry> = entries.iter().collect(); + newest.sort_unstable_by(|a, b| b.unix.cmp(&a.unix).then_with(|| a.id.cmp(&b.id))); + newest.truncate(HISTORY_CAP); + let mut body = String::new(); + for entry in newest { + match serde_json::to_string(entry) { + Ok(line) => { + body.push_str(&line); + body.push('\n'); + } + Err(_) => return, + } + } + let tmp = path.with_extension("jsonl.tmp"); + if std::fs::write(&tmp, body).is_ok() && std::fs::rename(&tmp, path).is_err() { + let _ = std::fs::remove_file(&tmp); + } +} + /// The event loop: draw, read input, drain worker replies, tick the poll. The /// resident console leaves only on quit or a dead connection; each terminal /// decision is streamed to stdout as it happens (when stdout is not a TTY). /// A `Fatal` phase is shown until a keypress, then exits. fn run(mut terminal: Tui, transport: &Transport, stream_decisions: bool) -> u8 { let mut model = Model::new(); + let mut history = HistoryLog::open(&mut model); let mut last_tick = Instant::now(); // Report the terminal size before anything else: the model approves only a @@ -191,6 +347,40 @@ fn run(mut terminal: Tui, transport: &Transport, stream_decisions: bool) -> u8 { { let _ = writeln!(io::stdout(), "{}", decision_line(&decision)); } + // History pipeline (Stage 7): stamp → persist → push back, in the + // SAME pass, before the next render (the order is normative). A + // decision made here is stamped `now`; a server outcome is + // stamped `arrival − age` (§3.9). + if let Some(d) = model.take_decided_outcome() { + history.record( + &mut model, + HistoryEntry { + unix: now_unix(), + id: d.id, + state: d.state, + to: Some(d.to), + amount_wei: Some(d.amount_wei), + chain_id: Some(d.chain_id), + tx_hash: d.tx_hash, + reason: d.reason, + }, + ); + } + for outcome in model.take_server_outcomes() { + history.record( + &mut model, + HistoryEntry { + unix: now_unix().saturating_sub(outcome.age_secs), + id: outcome.id, + state: outcome.state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: outcome.tx_hash, + reason: outcome.reason, + }, + ); + } } if last_tick.elapsed() >= POLL { @@ -321,6 +511,7 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { KeyCode::Enter => Some(Msg::Open), KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -334,6 +525,7 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { } => match key.code { KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -345,6 +537,20 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { } => match key.code { KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('h') => Some(Msg::View(View::Activity)), + KeyCode::Char('q') => Some(Msg::Quit), + _ => None, + }, + // Activity is display-only: navigation, the outcome filter, quit. + Phase::Watching { + confirm: None, + view: View::Activity, + .. + } => match key.code { + KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), + KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), + KeyCode::Char('r') => Some(Msg::View(View::Receive)), + KeyCode::Char('f') => Some(Msg::Filter), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -842,4 +1048,180 @@ mod tests { Some(Msg::View(View::Receive)) )); } + + // ── Stage 7: the Activity view's keys and the history log's file side ── + + fn activitying() -> Phase { + Phase::Watching { + items: vec![], + selected: 0, + confirm: None, + notice: None, + view: View::Activity, + } + } + + #[test] + fn h_reaches_activity_from_every_display_view_and_f_cycles_there() { + for phase in [watching(), receiving(), dashboarding()] { + assert!( + matches!( + map_key(&key(KeyCode::Char('h')), &phase), + Some(Msg::View(View::Activity)) + ), + "h opens the Activity view" + ); + } + assert!(matches!( + map_key(&key(KeyCode::Char('f')), &activitying()), + Some(Msg::Filter) + )); + assert!( + map_key(&key(KeyCode::Char('f')), &watching()).is_none(), + "the filter key exists only on the Activity view" + ); + assert!(matches!( + map_key(&key(KeyCode::Char('a')), &activitying()), + Some(Msg::View(View::Queue)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('q')), &activitying()), + Some(Msg::Quit) + )); + } + + use rustok_console::protocol::OutcomeState; + + fn tmp_log(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("rustok_console_hist_{}_{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp dir"); + dir.join("log.jsonl") + } + + fn hist(id: &str, unix: u64) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state: OutcomeState::Denied, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + #[test] + fn the_log_roundtrips_and_a_duplicate_id_keeps_the_newest_stamp() { + let path = tmp_log("roundtrip"); + append_history_line(&path, &hist("a", 10)).expect("append"); + append_history_line(&path, &hist("b", 20)).expect("append"); + // The same id again — a second console appended the same server + // outcome with a newer stamp (spec /check-3). + append_history_line(&path, &hist("a", 30)).expect("append"); + + let (entries, skipped) = load_history(&path); + assert_eq!(skipped, 0); + assert_eq!(entries.len(), 2, "load dedups by id"); + let a = entries.iter().find(|e| e.id == "a").expect("a survives"); + assert_eq!(a.unix, 30, "the newest stamp wins the dedup"); + } + + #[test] + fn corrupt_lines_are_skipped_and_counted_never_fatal() { + let path = tmp_log("corrupt"); + append_history_line(&path, &hist("good", 10)).expect("append"); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(b"{not json\ntail-without-brace")) + .expect("hostile bytes"); + + let (entries, skipped) = load_history(&path); + assert_eq!(entries.len(), 1, "the good line survives"); + assert_eq!(skipped, 2, "both hostile lines are counted, not fatal"); + // A missing file is an empty history, not an error. + assert_eq!(load_history(Path::new("/nonexistent/x.jsonl")), (vec![], 0)); + } + + #[test] + fn compaction_keeps_the_newest_cap_and_heals_the_file() { + let path = tmp_log("compact"); + let total = 2 * HISTORY_CAP + 1; + let mut body = String::new(); + for i in 0..total { + body.push_str(&serde_json::to_string(&hist(&format!("id{i:05}"), i as u64)).unwrap()); + body.push('\n'); + } + body.push_str("{corrupt line\n"); + std::fs::write(&path, body).expect("seed file"); + + let (entries, skipped) = load_history(&path); + assert_eq!(entries.len(), total); + assert_eq!(skipped, 1); + compact_history(&path, &entries); + + let (after, skipped_after) = load_history(&path); + assert_eq!(after.len(), HISTORY_CAP, "compaction keeps exactly the cap"); + assert_eq!( + skipped_after, 0, + "the corrupt line is gone — the file healed" + ); + assert!( + after + .iter() + .all(|e| e.unix > (total - 1 - HISTORY_CAP) as u64), + "what was dropped is exactly the oldest tail" + ); + } + + #[test] + fn record_appends_new_ids_and_fills_missing_on_known_ones() { + let path = tmp_log("record"); + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(path.clone()), + seen: HashSet::new(), + }; + + log.record(&mut model, hist("a", 10)); + assert_eq!(model.history().len(), 1); + // The same id again, now carrying a tx hash: fill-missing in memory, + // NO second file line (append-only keeps the first record). + let mut richer = hist("a", 11); + richer.tx_hash = Some("0xfeed".to_owned()); + log.record(&mut model, richer); + assert_eq!(model.history().len(), 1, "no duplicate row in the model"); + assert_eq!( + model.history()[0].tx_hash.as_deref(), + Some("0xfeed"), + "the missing detail filled in memory" + ); + let (on_disk, _) = load_history(&path); + assert_eq!(on_disk.len(), 1, "the file keeps its first record only"); + assert_eq!(on_disk[0].tx_hash, None); + } + + #[test] + fn a_failed_write_switches_to_session_only_with_a_visible_note() { + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(PathBuf::from("/nonexistent-dir/x.jsonl")), + seen: HashSet::new(), + }; + log.record(&mut model, hist("a", 10)); + assert!( + log.path.is_none(), + "persistence switched off after the failure" + ); + assert_eq!(model.history().len(), 1, "the session view still works"); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("not being persisted")), + "the degradation is visible, not silent" + ); + } } diff --git a/src/protocol.rs b/src/protocol.rs index c936de6..10a564d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1257,7 +1257,10 @@ mod tests { assert_eq!(outcomes[0].age_secs, 42); assert_eq!(outcomes[1].state, OutcomeState::Denied); assert_eq!( - (outcomes[1].tx_hash.as_deref(), outcomes[1].reason.as_deref()), + ( + outcomes[1].tx_hash.as_deref(), + outcomes[1].reason.as_deref() + ), (None, None), "absent wire fields read as None, never a fabricated value" ); diff --git a/src/transport.rs b/src/transport.rs index ac2935d..37a325c 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -24,9 +24,9 @@ use std::thread::JoinHandle; use zeroize::Zeroizing; use crate::protocol::{ - self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, PROTO_VERSION, PositionsOutcome, - ResolveOutcome, Summary, encode_request, parse_approve, parse_auth, parse_context, parse_deny, - parse_get, parse_hello, parse_list, parse_positions, + self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, OutcomeEntry, PROTO_VERSION, + PositionsOutcome, ResolveOutcome, Summary, encode_request, parse_activity, parse_approve, + parse_auth, parse_context, parse_deny, parse_get, parse_hello, parse_list, parse_positions, }; /// Informational client id sent in `hello` (the server does not validate it). @@ -56,6 +56,8 @@ pub enum Request { Context, /// Ask for the wallet's own DeFi positions (proto 2+, auth-gated). Positions, + /// Ask for the recent terminal outcomes (proto 2+, auth-gated, §3.9). + Activity, } /// A message from the worker to the MVU layer. @@ -78,6 +80,10 @@ pub enum Reply { Context(ContextOutcome), /// Result of a `positions` (proto 2+). Positions(PositionsOutcome), + /// Result of an `activity` (proto 2+): the retained window, newest first, + /// server-capped at 100 (§3.9). No degradable variant — any error code + /// fails the parse and ends the worker (see `parse_activity`). + Activity(Vec), /// The connection is finished and unusable — the worker has exited. Fatal(TransportError), } @@ -294,6 +300,11 @@ fn serve_one( .map_err(|e| TransportError::Protocol(e.to_string()))?; exchange(writer, reader, &line)? } + Request::Activity => { + let line = encode_request(&protocol::Request::Activity) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + exchange(writer, reader, &line)? + } }; let parsed = match req { Request::Auth(_) => parse_auth(&resp).map(Reply::Auth), @@ -303,6 +314,7 @@ fn serve_one( Request::Deny(_) => parse_deny(&resp).map(Reply::Resolve), Request::Context => parse_context(&resp).map(Reply::Context), Request::Positions => parse_positions(&resp).map(Reply::Positions), + Request::Activity => parse_activity(&resp).map(Reply::Activity), }; parsed.map_err(|e| TransportError::Protocol(e.to_string())) } diff --git a/src/ui.rs b/src/ui.rs index 8e13a3a..0e18246 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,9 +10,10 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; use crate::app::{ - AuthError, Confirm, DecisionKind, Model, Notice, Phase, Positions, ResolveError, View, + AuthError, Confirm, DecisionKind, HistoryEntry, Model, Notice, Phase, Positions, ResolveError, + View, }; -use crate::protocol::{Card, Summary}; +use crate::protocol::{Card, OutcomeState, Summary}; use crate::{format, qr, theme}; /// Render the whole screen for the current model. @@ -43,6 +44,7 @@ pub fn render(frame: &mut Frame, model: &Model, now_unix: u64) { ), View::Receive => render_receive(frame, items.len(), model.wallet_address()), View::Dashboard => render_dashboard(frame, items.len(), model), + View::Activity => render_activity(frame, items.len(), model, now_unix), }, Phase::Fatal(err) => render_centered(frame, &err.to_string()), } @@ -73,6 +75,8 @@ fn tab_line(active: View, pending: usize) -> Line<'static> { tab(format!(" Queue·{pending} [a] "), active == View::Queue), Span::raw(" "), tab(" Receive [r] ".to_owned(), active == View::Receive), + Span::raw(" "), + tab(" Activity [h] ".to_owned(), active == View::Activity), ]) } @@ -450,6 +454,120 @@ fn render_receive(frame: &mut Frame, pending: usize, wallet: Option<&str>) { frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); } +/// The Activity view: terminal outcomes newest-first — the local log merged +/// with the server's retained window (Stage 7). Pure display: nothing here +/// signs or gates. Addresses are SHORTENED (`format::short_addr`) — the one +/// display list ТЗ §4.1 allows it on; signing surfaces render in full. Rows +/// that do not fit end with an explicit "+N more" marker, never a silent +/// clip (the Stage-5 budget lesson: exact-fit vs marker split, one budget). +fn render_activity(frame: &mut Frame, pending: usize, model: &Model, now_unix: u64) { + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(frame.area()); + frame.render_widget(Paragraph::new(tab_line(View::Activity, pending)), chunks[0]); + + let block = themed_block(" Activity "); + let inner = block.inner(chunks[1]); + let height = usize::from(inner.height); + + let filter = model.history_filter(); + let mut lines: Vec> = vec![Line::from(Span::styled( + format!("filter: {} [f cycles]", filter.label()), + theme::label_style(), + ))]; + let note_lines = usize::from(model.history_note().is_some()); + + let rows: Vec<&HistoryEntry> = model + .history() + .iter() + .filter(|e| filter.admits(e.state)) + .collect(); + if rows.is_empty() { + let text = if model.history().is_empty() { + "no activity yet".to_owned() + } else { + format!("no {} outcomes under this filter", filter.label()) + }; + lines.push(Line::from(Span::styled(text, theme::label_style()))); + } else { + // The budget is computed ONCE from what is already reserved (header + + // footer note); the loop below never re-subtracts it. Exact fit shows + // everything; one over shows budget−1 rows + an honest marker. + let budget = height.saturating_sub(lines.len() + note_lines); + let total = rows.len(); + if total <= budget { + lines.extend(rows.iter().map(|e| activity_line(e, now_unix))); + } else if let Some(kept) = budget.checked_sub(1) { + lines.extend(rows[..kept].iter().map(|e| activity_line(e, now_unix))); + lines.push(Line::from(Span::styled( + format!("+{} more (history lives in the local log)", total - kept), + theme::label_style(), + ))); + } + // budget == 0: the block has no visible rows at all — nothing is + // being clipped in silence, the whole list is out of view. + } + if let Some(note) = model.history_note() { + lines.push(Line::from(Span::styled( + note.to_owned(), + theme::high_risk_style(), + ))); + } + frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); +} + +/// One outcome row. A rich record carries the card data written at decision +/// time; a server-only record renders an honest "(details not recorded)" +/// instead of fabricated columns. +fn activity_line(entry: &HistoryEntry, now_unix: u64) -> Line<'static> { + let (word, color) = match entry.state { + OutcomeState::Executed => ("approved", theme::approve()), + OutcomeState::Denied => ("rejected", theme::reject()), + OutcomeState::Expired => ("expired", theme::high_risk()), + OutcomeState::Failed => ("failed", theme::reject()), + }; + let mut spans = vec![ + Span::styled( + format!("{:>7} ", age_label(now_unix, entry.unix)), + theme::label_style(), + ), + Span::styled(format!("{word:<8}"), Style::new().fg(color)), + ]; + match (&entry.to, &entry.amount_wei) { + (Some(to), Some(amount)) => spans.push(Span::raw(format!( + " {} → {}", + format::wei_to_eth(amount), + format::short_addr(to) + ))), + _ => spans.push(Span::styled( + " (details not recorded)".to_owned(), + theme::label_style(), + )), + } + if let Some(tx) = &entry.tx_hash { + spans.push(Span::styled( + format!(" tx {}", format::short_addr(tx)), + theme::label_style(), + )); + } + if let Some(reason) = &entry.reason { + spans.push(Span::styled(format!(" {reason}"), theme::label_style())); + } + Line::from(spans) +} + +/// Compact "how long ago" for a history row (whole units, floor). +fn age_label(now_unix: u64, unix: u64) -> String { + let s = now_unix.saturating_sub(unix); + if s < 60 { + format!("{s}s ago") + } else if s < 3600 { + format!("{}m ago", s / 60) + } else if s < 86_400 { + format!("{}h ago", s / 3600) + } else { + format!("{}d ago", s / 86_400) + } +} + /// The Dashboard: per-chain balance (from `context`), DeFi positions (the /// `positions` read-op), and the "waiting for you" count. Pure display — /// nothing here signs or gates; the values render **verbatim** (`extra` are @@ -2169,4 +2287,155 @@ mod tests { "the active (home) tab is the Dashboard" ); } + + // ── Stage 7: the Activity view ── + + fn history(id: &str, unix: u64, state: OutcomeState) -> HistoryEntry { + HistoryEntry { + unix, + id: id.to_owned(), + state, + to: None, + amount_wei: None, + chain_id: None, + tx_hash: None, + reason: None, + } + } + + fn on_activity(entries: Vec) -> Model { + let mut m = Model::new(); + to_watching(&mut m, vec![]); + m.set_history(entries); + m.update(Msg::View(View::Activity)); + m + } + + #[test] + fn a_rich_row_shows_age_outcome_amount_and_a_shortened_address() { + let mut rich = history("r1", NOW - 120, OutcomeState::Executed); + rich.to = Some("0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb".to_owned()); + rich.amount_wei = Some("10000000000000000".to_owned()); + rich.tx_hash = Some("0xfeedfeedfeedfeed".to_owned()); + let m = on_activity(vec![rich]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with( + &rows, + &[ + "2m ago", + "approved", + "0.01 ETH", + "0x489Fe0…bbbb", + "tx 0xfeedfe…feed" + ] + ), + "age, outcome, human amount, SHORTENED address and tx on one row: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.contains("0x489Fe09Fbb489Fe09Fbb")), + "the Activity list never carries the full address — display, not signing" + ); + } + + #[test] + fn a_server_only_row_admits_it_has_no_details() { + let m = on_activity(vec![history("s1", NOW - 3600, OutcomeState::Expired)]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["1h ago", "expired", "(details not recorded)"]), + "a poor record renders honestly, no fabricated columns: {rows:?}" + ); + } + + #[test] + fn an_empty_history_and_an_empty_filter_speak_up() { + let mut m = on_activity(vec![]); + let rows = draw_rows(&m, 80, 24); + assert!(has_line_with(&rows, &["no activity yet"])); + assert!( + has_line_with(&rows, &["filter: all", "[f cycles]"]), + "the filter header names the active filter: {rows:?}" + ); + + // One denied entry, filter cycled to `executed`: nothing matches. + m.set_history(vec![history("d1", NOW - 5, OutcomeState::Denied)]); + m.update(Msg::Filter); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["no executed outcomes under this filter"]), + "an empty filtered view says which filter hides the rows: {rows:?}" + ); + } + + #[test] + fn the_outcome_word_carries_its_semantic_color() { + use ratatui::backend::TestBackend; + let m = on_activity(vec![history("d1", NOW - 5, OutcomeState::Denied)]); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &m, NOW)).unwrap(); + let buffer = terminal.backend().buffer(); + let mut found = false; + for y in 0..24u16 { + let row: String = (0..80).map(|x| buffer[(x, y)].symbol()).collect(); + if let Some(at) = row.find("rejected") { + assert_eq!( + buffer[(u16::try_from(at).unwrap(), y)].style().fg, + Some(theme::reject()), + "a rejection reads in the reject color" + ); + found = true; + } + } + assert!(found, "the rejected row renders"); + } + + #[test] + fn overflowing_history_ends_with_an_honest_marker() { + // 80×24: 1 tab row + 2 borders leave 21 inner rows; the filter header + // takes 1 → a 20-row budget. Exactly 20 entries fit without a marker; + // 21 show 19 + "+2 more" (the Stage-5 exact-fit/marker split). + let exact: Vec = (0u64..20) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let m = on_activity(exact); + let rows = draw_rows(&m, 80, 24); + assert!( + !rows.iter().any(|r| r.contains("more")), + "an exact fit needs no marker: {rows:?}" + ); + + let over: Vec = (0u64..21) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let m = on_activity(over); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["+2 more"]), + "one over budget keeps budget−1 rows and says what is hidden: {rows:?}" + ); + } + + #[test] + fn the_tab_bar_carries_the_activity_tab() { + let mut m = Model::new(); + to_watching(&mut m, vec![]); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["Queue·0 [a]", "Receive [r]", "Activity [h]"]), + "all four tabs share the 80-column header row: {rows:?}" + ); + } + + #[test] + fn the_history_note_renders_as_a_footer() { + let mut m = on_activity(vec![]); + m.set_history_note("history is session-only — set RUSTOK_CONSOLE_LOG".to_owned()); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["session-only"]), + "log degradation is visible on the view: {rows:?}" + ); + } } From 4c94e621330bb6044d3835256c6650bce40a145d Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 15:17:33 +0500 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20Stage-7=20spec=20(Gate-1=20signed)?= =?UTF-8?q?=20+=20DoD=20smoke=20=E2=80=94=20pty,=20two=20runs,=20log=20rel?= =?UTF-8?q?oad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/reports/2026-07-14-smoke-activity.py | 254 ++++++++++++++++++ ...026-07-14-console-v2-phase2-s7-activity.md | 216 +++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 .claude/reports/2026-07-14-smoke-activity.py create mode 100644 .claude/specs/2026-07-14-console-v2-phase2-s7-activity.md diff --git a/.claude/reports/2026-07-14-smoke-activity.py b/.claude/reports/2026-07-14-smoke-activity.py new file mode 100644 index 0000000..a33fd72 --- /dev/null +++ b/.claude/reports/2026-07-14-smoke-activity.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""DoD smoke — Console v2 Этап 7 Activity. + +Real binary in a pty against a proto-2 stub, TWO runs over one log file: + run 1: unlock → h (server outcomes land as poor rows) → f (filter) → + a → Enter → y (approve executes) → h (rich row is there INSTANTLY) + → q. Decision stream: exactly one JSON line on piped stdout. + run 2: fresh process, stub answers activity with [] — every row must come + back from the JSONL log on disk (persistence proven). +""" + +import fcntl +import json +import os +import pty +import re +import socket +import struct +import subprocess +import sys +import termios +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRATCH = "/tmp/claude-1000/-home-temrjan-Dev/0252c58e-cae7-4976-84d5-5dfa0d509cf8/scratchpad" +SOCK = os.path.join(SCRATCH, "approve7.sock") +LOG = os.path.join(SCRATCH, "console-activity.jsonl") +BIN = os.path.join(HERE, "../../target/debug/rustok-console") + +ITEM_ID = "aaaaaaaa-0000-0000-0000-000000000001" +TO = "0x1111222233334444555566667777888899990000" +TX = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +state = {"connections": 0, "approved": False} + + +def serve(listener: socket.socket) -> None: + while True: + conn, _ = listener.accept() + state["connections"] += 1 + first_run = state["connections"] == 1 + f = conn.makefile("rw", encoding="utf-8") + for line in f: + req = json.loads(line) + op = req.get("op") + if op == "hello": + resp = {"ok": True, "proto": req["proto"], "server": "stub/0.0"} + elif op == "auth": + resp = {"ok": True} + elif op == "context": + resp = {"ok": True, "address": TO, "balances": [], "allowed_chains": [1]} + elif op == "positions": + resp = {"ok": True, "positions": []} + elif op == "activity": + if first_run: + resp = {"ok": True, "outcomes": [ + {"id": "bbbbbbbb-0000-0000-0000-000000000002", + "state": "denied", "age_secs": 120}, + {"id": "cccccccc-0000-0000-0000-000000000003", + "state": "expired", "age_secs": 1800}]} + else: + # Run 2: the server window is empty — whatever the view + # shows must have come from the file. + resp = {"ok": True, "outcomes": []} + elif op == "list": + pending = [] if (state["approved"] or not first_run) else [{ + "id": ITEM_ID, "kind": "send", "chain_id": 1, "to": TO, + "amount_wei": "1000000000000000", "risk": "safe", + "high_risk": False, "not_after_unix": int(time.time()) + 600}] + resp = {"ok": True, "pending": pending} + elif op == "get": + resp = {"ok": True, "card": { + "id": ITEM_ID, "chain_id": 1, "to": TO, + "amount_wei": "1000000000000000", "decoded_call": None, + "raw_data": "0x", "high_risk": False, "high_risk_reasons": [], + "not_after_unix": int(time.time()) + 600}} + elif op == "approve": + state["approved"] = True + resp = {"ok": True, "state": "executed", "tx_hash": TX} + else: + resp = {"ok": False, "error": "protocol_error"} + f.write(json.dumps(resp) + "\n") + f.flush() + + +CSI = re.compile(r"\x1b\[([0-9;?]*)([A-Za-z])") + + +def screen_of(stream: str, rows: int = 24, cols: int = 80) -> list[str]: + """Rebuild the final screen from a raw ANSI stream: ratatui DIFFS frames + (a settled screen emits nothing; words split across cursor jumps), so + substring asserts must run on the reconstructed grid, not the bytes.""" + grid = [[" "] * cols for _ in range(rows)] + r = c = 0 + i = 0 + while i < len(stream): + ch = stream[i] + if ch == "\x1b": + m = CSI.match(stream, i) + if m: + params, final = m.group(1), m.group(2) + if final == "H": + parts = params.split(";") if params else [] + r = int(parts[0]) - 1 if parts and parts[0] else 0 + c = int(parts[1]) - 1 if len(parts) > 1 and parts[1] else 0 + r = min(max(r, 0), rows - 1) + c = min(max(c, 0), cols - 1) + elif final == "J": + grid = [[" "] * cols for _ in range(rows)] + i = m.end() + continue + i += 1 + continue + if ch == "\r": + c = 0 + elif ch == "\n": + r = min(r + 1, rows - 1) + elif ch >= " ": + grid[r][c] = ch + c = min(c + 1, cols - 1) + i += 1 + return ["".join(row) for row in grid] + + +def on_screen(screen: list[str], *fragments: str) -> bool: + return all(any(f in row for row in screen) for f in fragments) + + +def launch(): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.set_blocking(master, False) + proc = subprocess.Popen( + [BIN], stdin=slave, stdout=subprocess.PIPE, stderr=slave, + env={**os.environ, "RUSTOK_APPROVE_SOCK": SOCK, + "RUSTOK_CONSOLE_LOG": LOG, "TERM": "xterm-256color"}, + ) + os.close(slave) + return proc, master + + +def main() -> int: + for p in (SOCK, LOG): + if os.path.exists(p): + os.unlink(p) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(SOCK) + listener.listen(2) + threading.Thread(target=serve, args=(listener,), daemon=True).start() + + captured = bytearray() + + def drain(master, seconds: float) -> None: + end = time.time() + seconds + while time.time() < end: + try: + captured.extend(os.read(master, 65536)) + except BlockingIOError: + time.sleep(0.05) + except OSError: + break + + def press(master, keys: str, wait: float = 0.7) -> None: + os.write(master, keys.encode()) + drain(master, wait) + + def snap() -> list[str]: + return screen_of(bytes(captured).decode("utf-8", "replace")) + + # ── run 1 ── + proc, master = launch() + drain(master, 1.0) + press(master, "1\r", wait=1.0) # PIN → Dashboard + press(master, "h", wait=0.3) # → Activity (marks stale) + drain(master, 3.5) # poll → list → activity dispatch + scr_act = snap() + press(master, "f", wait=0.6) # filter → executed (nothing matches) + scr_filt = snap() + for _ in range(4): # cycle back to All + press(master, "f", wait=0.2) + press(master, "a", wait=0.5) # → Queue + drain(master, 3.0) # a list poll delivers the pending item + press(master, "\r", wait=1.0) # open the card + press(master, "y", wait=1.0) # approve → executed + press(master, "h", wait=0.8) # → Activity: the rich row is INSTANT + scr_rich = snap() + press(master, "q", wait=0.5) + proc.wait(timeout=5) + stdout_1 = proc.stdout.read().decode() + os.close(master) + + # ── run 2: fresh process, empty server window — the file must speak ── + captured.clear() + proc2, master2 = launch() + drain(master2, 1.0) + press(master2, "1\r", wait=1.0) + press(master2, "h", wait=0.8) + scr_reload = snap() + press(master2, "q", wait=0.5) + proc2.wait(timeout=5) + os.close(master2) + + log_lines = [] + if os.path.exists(LOG): + with open(LOG, encoding="utf-8") as fh: + log_lines = [json.loads(line) for line in fh if line.strip()] + + decision_lines = [line for line in stdout_1.splitlines() if line.strip()] + + checks = [ + ("Activity tab renders with the filter header", + on_screen(scr_act, "Activity [h]", "filter: all")), + ("server outcomes render as honest poor rows", + on_screen(scr_act, "rejected", "expired", "(details not recorded)")), + ("filter hides what does not match", + on_screen(scr_filt, "no executed outcomes under this filter") + and not on_screen(scr_filt, "rejected")), + ("the decision births a rich row instantly", + on_screen(scr_rich, "approved", "0.001 ETH")), + ("addresses are shortened on the list", + on_screen(scr_rich, "0x111122…0000") + and not on_screen(scr_rich, TO)), + ("the tx hash rides the rich row", + on_screen(scr_rich, "tx 0xdeadbe…beef")), + ("exactly one decision line on piped stdout (ADR #7)", + len(decision_lines) == 1 and "approved" in decision_lines[0] + and TX in decision_lines[0]), + ("the log file holds all three outcomes", + len(log_lines) == 3 and {e["id"] for e in log_lines} + == {ITEM_ID, "bbbbbbbb-0000-0000-0000-000000000002", + "cccccccc-0000-0000-0000-000000000003"}), + ("the rich record persisted with card data", + any(e.get("to") == TO and e.get("tx_hash") == TX for e in log_lines)), + ("run 2 reloads history from the file (server window empty)", + on_screen(scr_reload, "approved", "0.001 ETH", "rejected")), + ("both runs exit aborted (6)", + proc.returncode == 6 and proc2.returncode == 6), + ] + + for tag, screen in [("act", scr_act), ("filt", scr_filt), + ("rich", scr_rich), ("reload", scr_reload)]: + with open(os.path.join(SCRATCH, f"screen-{tag}.txt"), "w") as out: + out.write("\n".join(screen)) + ok = True + for name, passed in checks: + print(f" {'PASS' if passed else 'FAIL'} {name}") + ok &= passed + print(f"\nsmoke: {'PASS' if ok else 'FAIL'} ({sum(p for _, p in checks)}/{len(checks)})") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md b/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md new file mode 100644 index 0000000..d529dba --- /dev/null +++ b/.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md @@ -0,0 +1,216 @@ +# Console v2 Фаза 2, Этап 7 (ПОСЛЕДНИЙ) — вид Activity + локальный лог исходов + +> Статус: **ПРЕДЛОЖЕНИЕ, ждёт Гейта-1.** Кода нет до двух подписей. +> Управляющие: ТЗ §2 (вид «Activity / History: список решений с фильтрами | локальный лог + +> retained outcomes»), §5 Фаза 2 («Локальный лог активности — терминальные исходы переживают +> 60-мин retention ядра»), §9.5 порядок (Этап 7 — последний Фазы 2); канон §3.9 (op готов, +> core PR3 смержен: id/state/tx_hash?/reason?/age_secs, кап 100, newest first, **id = ключ +> дедупа локального лога**, «console derives an absolute time locally (answer arrival − age) +> when writing its local log»), §3.8 (нормативное клиентское правило list-first). +> Сервер-сторона ГОТОВА (core#93) — этот этап чисто console. +> Команда: Капитан/Инженер/Ревьюер; связь только через Капитана. + +## Цель — один абзац + +Console-сторона activity, закрывающая Фазу 2: четвёртый вид **Activity/History** (клавиша +`h`) — список терминальных исходов с фильтром по состоянию; клиентская часть op'а +(`Request::Activity` + парс §3.9); **локальный журнал** (JSONL на персистентном томе +`/data`) — история глубже 60-мин retention ядра, куда richly пишется каждое решение в +момент принятия (to/amount из живой карточки) и bedно доливаются server-only исходы +(экспайры, lockout-лавины, чужие соединения) из activity-ответов; возврат +`format::short_addr` — ТОЛЬКО для дисплей-строк этого списка (ТЗ §4.1). + +## Замер blast radius (сделан ДО спеки, console main `1b7a16e`, 184+19+1) + +1. **Компилятор-форс** от `View::Activity`: render-диспетч (ui.rs:34), три view-блока + `map_key` (main.rs:316-348 — по 1 строке `h` в каждый + новый блок Activity). + Существующие таб-тесты — substring-ассерты (ui.rs:1701, :1718) — 4-ю вкладку + ПЕРЕЖИВАЮТ (/check-4): ожидается ноль правок существующих тестов. +2. **protocol.rs — аддитивно**: `Request::Activity` (serde-tag, парс существующих op не + меняется) + `OutcomeEntry` + `parse_activity` — зеркало `parse_positions` (:554), но БЕЗ + `wallet_locked`-ветки: §3.9 фиксирует словарь `unauthorized | protocol_error`, оба на + корректной authed proto-2 сессии невозможны → любые `ok:false` = `Unexpected` → Fatal + (класс fail-closed, тот же что `apply_resolve` на Unauthorized). +3. **transport.rs — аддитивно**: `Request::Activity`, `Reply::Activity(Vec)`, + 2 арма worker-диспетча (:287-305 паттерн Positions). +4. **app.rs — поведенческое ядро**: `ReadOp::Activity` + правка гейта `dispatch_read_op` + (:901-929, сейчас только `View::Dashboard`); `Reply::Activity`-арм; history-модель + (`Vec` + фильтр + дренажи/пуши). **`Decision`/`decision_line` НЕ трогаются** + (ADR-поверхность инварианта #7) — история идёт отдельным дренажем. +5. **main.rs**: ключи, файл-I/O лога, дренажи в цикле (:185-194 — рядом с take_decision). +6. **format.rs**: возврат `short_addr` (убран в Фазе 1 как dead code — бэклог-пункт «вернуть + в Фазе 2 только для дисплей-списков»). +7. Существующие тесты: правятся ТОЛЬКО tab_line-ассерты; 5 тест-хелперов с явным view, + scheduler-тесты Этапа 5 (Dashboard-ротация), decision_line-тесты — без правок (критерий 5). + +## Ключевые факты (проверены по коду) + +- `apply_resolve` держит живую карточку в момент терминального ответа (`c.card` до + `confirm = None`, app.rs:1056-1084) → id/to/amount_wei/chain_id доступны для rich-записи. +- Модель чистая: ни wall-clock, ни ФС (урок Этапа 2) → все timestamps и файл — в main.rs. +- `/data` — персистентный том wallet-образа (mcp/Dockerfile.wallet: `VOLUME /data`, + `ENV RUSTOK_DATA_DIR=/data`, chown rustok; `docker exec` наследует ENV и uid образа). +- Транспорт-кап 64 KiB на строку ответа; сервер капнул 100 исходов ≈ 16 KiB — проходит. +- `on_open` уже гейтит `view != Queue` (:777) — Msg::Open из Activity структурно мёртв; + `on_view` уже отказывает при карточке/get-на-проводе/parked-Get (:600) — Activity + недостижим при открытой карточке теми же существующими гейтами. +- MoveUp/Down двигают queue-selected независимо от view — существующее безвредное + поведение Dashboard/Receive, для Activity не меняется (правило №6). + +## Дизайн — предрешённые решения + +1. **Пайплайн истории: main.rs владеет файлом, часами и dedup-set; модель — чистый + `Vec` + фильтр.** Три потока: + - *Загрузка*: main читает JSONL → `model.set_history(entries)` до цикла; dedup-set = id + загруженных. + - *Решение (rich)*: `apply_resolve` при терминальном исходе кладёт запись (id, state, + to **verbatim-строкой как хранение** — рендер сокращает, amount_wei, chain_id, + tx_hash/reason) в дренаж `take_history_entry()` — БЕЗ unix (у модели нет часов); + main: unix = now → append в файл → `model.push_history(entry)` **в том же + проходе цикла, до рендера** (порядок нормативен). Файл отказал → push всё равно + (session-only деградация, футер-нота). + - *Activity-реплай (server-only)*: `apply_activity` складывает server-исходы в дренаж + `take_server_outcomes()`; main: id уже в dedup-set → **fill-missing merge в памяти** + (/check-2: `ResolveOutcome::AlreadyResolved{state}` несёт только слово — чужое + соединение исполнило item, наша rich-запись без tx_hash; пустые tx_hash/reason + добираются из server-исхода через `model.fill_history_detail(id, …)`, присутствующие + поля НЕ перезаписываются; файл append-only — корректива в файл не пишется, честная + цена угла); новый id → unix = `now.saturating_sub(age_secs)` → append + push. Возраст + дрейфует между поллами — дедуп по id это гасит (канон §3.9). +2. **Хранимое слово состояния = протокольное** (`executed|denied|expired|failed`, словарь + §3.5/§3.9); рендер маппит в человеческие (как Notice::Outcome). Rich-запись конвертирует + DecisionKind → протокольное слово в одной именованной функции (шов, тест). +3. **Файл**: JSONL append-only, unknown-поля игнорятся (мягкая эволюция без version-поля), + битая строка → скип + футер-нота «log partially unreadable». **Загрузка дедупит по id** + (/check-3: две консоли конкурентно аппендят один файл — один server-исход может лечь + двумя строками; newest wins при загрузке). Путь-каскад: + `RUSTOK_CONSOLE_LOG` → `$RUSTOK_DATA_DIR/console-activity.jsonl` → ни того ни другого = + персистентность отключена (session-only, футер-нота). Кап: в памяти/показе newest 500 + **валидных записей** (/check-5: битые строки не считаются и при компакции выбрасываются + — файл самочинится); компакция при загрузке — >1000 валидных → перезапись newest 500 + дедупнутых (tmp рядом + atomic rename, тот же fs); компакция best-effort (dir не + writable → скип, чтение работает). +4. **Scheduler**: `ReadOp::Activity` на том же list-first слоте. `dispatch_read_op`: + `View::Dashboard` → чередование Context/Positions (НЕ меняется); `View::Activity` → + Activity-op; Queue/Receive → None. Та же `STALE_TICKS`-каденция (~30 c; store-чтение + дешевле on-chain, но один канал и один слот — консистентность дороже частоты). Вход в + Activity метит стейл (зеркало Dashboard-ветки `on_view` :610). Все существующие гейты + слота (flush_pending первым, тройная чистота, только из on_reply(List)) — без изменений. +5. **Вид Activity**: строки newest-first (сортировка по unix desc, **вторичный ключ id + asc** — lockout-пачка даёт равные unix, детерминизм зеркалит серверную семантику PR3; + /check-6; модель сортирует при push/set); формат строки: `<возраст> · <исход> · <сумма ETH> → ` + + `tx 0x…кор.` у executed; server-only запись (без to/amount): `<возраст> · <исход> · + (details not recorded)` — колонки не разъезжаются, одна честная форма. Возраст из + `render(_, now_unix)` (уже в сигнатуре) минус unix записи. Фильтр `f` циклит + All→Executed→Denied→Expired→Failed (модельное поле, живёт только в Activity-блоке + map_key); активный фильтр — в шапке списка. Пустые состояния: «no activity yet» / + «no outcomes (filter: f)». Overflow-бюджет — урок Этапа 5: budget один раз, + в цикле `used`-счётчик, резерв маркера «+K more» кроме последней строки. Notice в + Activity не рендерится (как Receive). Скролла НЕТ (решение №3 ниже). +6. **short_addr** (format.rs): `0x` + первые 6 + `…` + последние 4 значащих; юнит-тесты + (длина/крайние). Используется ТОЛЬКО в render_activity — ни на одной + подписывающей/одобряющей поверхности (ТЗ §4.1; карточка/Receive не тронуты). +7. **Ключи**: `h` → Activity из Queue/Dashboard/Receive; из Activity: `a`/Esc → Queue, + `d` → Dashboard, `r` → Receive, `q` — quit, `f` — фильтр. `f` свободна во всех блоках. + +## Скоуп PR — что входит / что ЯВНО не входит + +**Входит:** всё выше + тесты каждого куска red→green + DoD-смоук (pty против proto-2 +Python-стаба, стаб дополняется ответом activity). + +**ЯВНО НЕ входит:** +- core/сервер — ноль изменений (op готов, core#93). +- Канон-док — ноль (клиентское правило уже в §3.8; §3.9 полон). +- **Activity-строка на Dashboard** (ТЗ §2 числит activity в данных Dashboard) — отдельный + мини-круг: у render_dashboard свой overflow-бюджет (зона блокера Этапа 5), не смешивать. + [решение №1] +- Скролл истории [решение №3], поиск, экспорт. +- Реакция вида на `Notice` — не меняется. +- Правка Decision/decision_line (ADR #7) — запрещена этим скоупом. + +## Затронутые файлы + +`console/src/format.rs` (+short_addr) · `protocol.rs` (+Request::Activity, +OutcomeEntry, ++parse_activity) · `transport.rs` (+Request/Reply армы) · `app.rs` (+View::Activity, ++ReadOp::Activity, +history-модель/дренажи, +apply_activity) · `ui.rs` (+render_activity, +tab_line) · `main.rs` (+ключи, +файл-I/O, +дренажи цикла). Тесты — в тех же файлах. + +## Критерии приёмки — «PR готов, когда…» + +1. **Red→green на каждый кусок**: parse_activity (все 4 состояния verbatim, отсутствие + tx_hash/reason = None, пустой массив, ok:false → Unexpected); transport-армы; + scheduler — зеркала тестов Этапа 5 (activity ТОЛЬКО из on_reply(List) при + view==Activity && stale && чистый слот; тик не рождает; Dashboard-ротация не изменилась + — существующие тесты без правок); history (rich при решении с to/amount живой карточки; + server-merge: новый id входит, знакомый — first-write-wins; сортировка unix desc); + файл-I/O (загрузка, append, компакция >1000→500, битая строка → скип+нота, нет пути → + session-only+нота); фильтр-цикл; short_addr; вид (снапшоты строк rich/бедной, + fg-ассерты, пустые состояния, overflow «+K more» на точной границе); tab_line 4 вкладки; + ключи h/f. +2. **Лог переживает retention**: тест/смоук — записи старше окна сервера остаются в виде + (из файла), server-окно их не дублирует. +3. **Инварианты §4**: short_addr только в activity-списке; полные адреса + карточки/Receive/QR не тронуты; fit-гейт карточки не тронут; секретов в + логе нет (лог не содержит PIN/сид — только публичные поля исхода). +4. **Порядок цикла нормативен**: дренаж → stamp → append → push → рендер в одном проходе. +5. **decision_line байт-в-байт**: существующие decision_line/Decision тесты БЕЗ правок. +6. **Существующие тесты: ожидается НОЛЬ правок** (/check-4: таб-тесты — substring-ассерты, + ui.rs:1701 `has_line_with`, :1718 `row.find` — 4-я вкладка аппендится и они выживают; + 4 вкладки ≈ 59 колонок из 80). Любая фактическая правка — поимённо с причиной в отчёте. +7. **Гейты — точные команды CI console** (ci.yml:29/39/48/56): `cargo fmt --all --check` · + `cargo clippy --all-targets -- -D warnings` · `cargo test` · + `cargo deny check advisories licenses bans sources` (/check-1: джоб Advisories/Licenses/ + Bans проверяет все четыре, не только advisories). +8. **DoD-смоук** pty против дополненного стаба: вход → h → вид с историей; решение по + карточке → строка появляется мгновенно (rich); стаб-исход другого происхождения → + появляется после полла (бедная строка); лог-файл на диске; **повторный запуск консоли + подхватывает историю из файла**; f-фильтр работает; stdout чист от JSON в интерактиве. + +## Definition of Done + +merged · ветка удалена · CI зелёный · smoke-скрипт сохранён в .claude/reports/ · трекер +console/workflow-state «Этап 7 done, Фаза 2 ЗАКРЫТА» · ТЗ §9.5 + §2-таблица отметки · +human-plan.html освежён (закрытие сессии) · NEXT = релизный поезд §8. + +## Решения к ратификации (Гейт-1) — предрешены + +1. **Dashboard activity-строка — НЕ в этом круге.** (а) Рекомендация: view-only; + (б) почему: у render_dashboard свой хрупкий overflow-бюджет (место блокера Этапа 5) — + отдельный маленький круг после; Этап 7 в формулировке хэндоффа Капитана строки не + содержит; (в) контраргумент: ТЗ-таблица §2 числит activity в данных Dashboard — + недобранная буква ТЗ; не перевесил: §8 всё равно релизит Фазу 2 пакетом, строка не + двигает time-to-value; (г) цена ошибки: один дополнительный мини-круг; (д) переключатель: + слово Капитана «в этом круге». +2. **Формат/путь лога**: JSONL + каскад RUSTOK_CONSOLE_LOG → $RUSTOK_DATA_DIR/… → + session-only; кап 500/1000 с best-effort компакцией. (а)-(б): том /data — факт образа, + JSONL = скип битой строки без потери файла; (в) контраргумент: sqlite надёжнее — не + перевесил (новая зависимость против append-only журнала, правило №5); (г) цена: формат + приватен консоли, мигрируем свободно; (д) переключатель: потребность в поиске/экспорте. +3. **Скролла нет**: топ-N в бюджет + честный «+K more»; фильтры дают срез; файл = полная + история. (в) контраргумент: длинная история не читается с экрана — не перевесил: + Activity — дисплей-обзор, не аудит-инструмент; (д) переключатель: боль на живом + использовании → мини-круг «скролл». +4. **Та же каденция STALE_TICKS для activity** (не чаще): один канал, один слот, list-first + дороже свежести. Переключатель: жалоба на отставание вида. + +## Тест-план + +Юнит: format (short_addr ×3) · protocol (parse_activity ×5) · transport (армы ×2) · +app-scheduler (×6: view-гейт, stale-гейт, list-first, тик-не-рождает, вход-метит, +Dashboard-ротация нетронута) · app-history (×6: rich-дренаж, server-дренаж, сортировка, +фильтр-цикл, push-порядок, **два rich-решения в одну секунду → id-tiebreak — осознанная +документированная особенность, НЕ хронология: unix — единственный источник времени у +чистой модели, секундная гранулярность неизбежна; находка Гейта-1**) · +main-I/O (×5: load/append/compact/битая/нет-пути) · +ui (×6: снапшоты rich/poor/empty/filter/overflow, tab_line 4 вкладки на 80 колонках — +покрывает /check-7, fg-ассерт) · ключи (×2). +Каждый — red→green (мутация или true-red), пруфы в отчёте. Смоук — критерий 8. + +## Замечено, не трогаю + +1. ТЗ §2-таблица: Dashboard-данные включают activity — если реш. №1 ратифицировано, + отметить строку как «Activity-строка Dashboard — бэклог» при правке ТЗ на закрытии. +2. MoveUp/Down двигают невидимый queue-selected на не-Queue видах — существующее, + безвредное, не трогаю. +3. `console/README`/доки образа не описывают RUSTOK_CONSOLE_LOG — вписать одну строку + доков при релизном поезде §8 (не этот PR). From 718c5942cee8877a154584b8e1bd91fb9603e2b3 Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 15:59:20 +0500 Subject: [PATCH 5/6] fix(console): field-merge on log load, honest read failures (Gate-2 blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKER-1: load_history deduped by whole-record replace on the bigger unix — but a rich decision record (unix = now) and a poor server-window record (unix = arrival − age) come from DIFFERENT formulas, so an ordinary two-console session could trade rich details for a poor duplicate on the next reload. Load now merges field-by-field in file order through the one shared seam HistoryEntry::fill_missing_from (present fields are never overwritten); Model::fill_history_detail takes &HistoryEntry (five positional Options were a silent-swap hazard). BLOCKER-2: read_to_string flattened EVERY read error into 'no history yet' — a torn non-UTF8 tail after a SIGKILL mid-append silently hid the whole file. The log is now read as bytes (a torn write poisons one line, counted and noted), and NotFound (normal empty) is distinguished from unreadable (surfaced as a note, never a healthy empty view). Minors folded in: writers serialize on a sidecar lockfile (compaction re-reads fresh under the lock — the TOCTOU race; the sidecar because rename would strand a waiter on the old inode) · compaction triggers on RAW line count and re-fires every HISTORY_CAP appends in a resident session · per-pid tmp name · 0600 on log/lock/tmp. --- src/app.rs | 115 ++++++++------ src/main.rs | 437 +++++++++++++++++++++++++++++++++++++++++----------- src/ui.rs | 60 ++++++-- 3 files changed, 469 insertions(+), 143 deletions(-) diff --git a/src/app.rs b/src/app.rs index 79acb79..e0ba804 100644 --- a/src/app.rs +++ b/src/app.rs @@ -304,6 +304,33 @@ pub struct HistoryEntry { pub reason: Option, } +impl HistoryEntry { + /// Fill this entry's MISSING fields from another record of the same + /// outcome — present fields are never overwritten. The ONE merge seam + /// for every duplicate-id path: the in-session fill + /// ([`Model::fill_history_detail`]) and the load-time dedup (Gate-2 + /// BLOCKER-1: a whole-record replace by timestamp traded a rich record + /// for a poor one written by a second console — the stamps come from + /// different formulas and are not comparable). + pub fn fill_missing_from(&mut self, other: &HistoryEntry) { + if self.to.is_none() { + self.to.clone_from(&other.to); + } + if self.amount_wei.is_none() { + self.amount_wei.clone_from(&other.amount_wei); + } + if self.chain_id.is_none() { + self.chain_id = other.chain_id; + } + if self.tx_hash.is_none() { + self.tx_hash.clone_from(&other.tx_hash); + } + if self.reason.is_none() { + self.reason.clone_from(&other.reason); + } + } +} + /// A decision made on THIS console, drained by the run loop /// ([`Model::take_decided_outcome`]) to be stamped (unix = now), appended to /// the local log, and pushed back via [`Model::push_history`]. The timestamp @@ -731,38 +758,18 @@ impl Model { self.sort_and_cap_history(); } - /// Fill MISSING detail on a known entry (spec /check-2), both ways: an - /// item resolved by another connection leaves our rich record without - /// tx_hash/reason (the server window carries them); a server-only record - /// gains to/amount/chain when the same id is later decided here. Present - /// fields are never overwritten; the file keeps its first record - /// (append-only) — this enriches the session view only. - pub fn fill_history_detail( - &mut self, - id: &str, - to: Option<&str>, - amount_wei: Option<&str>, - chain_id: Option, - tx_hash: Option<&str>, - reason: Option<&str>, - ) { - let Some(entry) = self.history.iter_mut().find(|e| e.id == id) else { - return; - }; - if entry.to.is_none() { - entry.to = to.map(str::to_owned); - } - if entry.amount_wei.is_none() { - entry.amount_wei = amount_wei.map(str::to_owned); - } - if entry.chain_id.is_none() { - entry.chain_id = chain_id; - } - if entry.tx_hash.is_none() { - entry.tx_hash = tx_hash.map(str::to_owned); - } - if entry.reason.is_none() { - entry.reason = reason.map(str::to_owned); + /// Fill MISSING detail on the known entry with `from.id` (spec /check-2), + /// both ways: an item resolved by another connection leaves our rich + /// record without tx_hash/reason (the server window carries them); a + /// server-only record gains to/amount/chain when the same id is later + /// decided here. Present fields are never overwritten (the shared + /// [`HistoryEntry::fill_missing_from`] seam); the file keeps its first + /// record (append-only) — this enriches the session view only. Takes the + /// whole record, not positional Options — five same-typed parameters + /// were a silent-swap hazard (Gate-2 МИНОР-9). + pub fn fill_history_detail(&mut self, from: &HistoryEntry) { + if let Some(entry) = self.history.iter_mut().find(|e| e.id == from.id) { + entry.fill_missing_from(from); } } @@ -3401,29 +3408,49 @@ mod tests { #[test] fn fill_history_detail_fills_only_the_missing_fields() { + // Gate-2 МИНОР-6: every one of the five fields is exercised in BOTH + // directions — present (never overwritten) and missing (filled), + // reason included with a real value. let mut m = Model::new(); - let mut rich = history_entry("a", 10, OutcomeState::Executed); - rich.to = Some("0xTO".to_owned()); - m.set_history(vec![rich]); + let mut base = history_entry("a", 10, OutcomeState::Failed); + base.to = Some("0xTO".to_owned()); + base.reason = Some("broadcast failed".to_owned()); + m.set_history(vec![base]); + + let mut from = history_entry("a", 99, OutcomeState::Failed); + from.to = Some("0xEVIL".to_owned()); + from.amount_wei = Some("5".to_owned()); + from.chain_id = Some(1); + from.tx_hash = Some("0xfeed".to_owned()); + from.reason = Some("a different story".to_owned()); + m.fill_history_detail(&from); - m.fill_history_detail( - "a", - Some("0xEVIL"), - Some("5"), - Some(1), - Some("0xfeed"), - None, - ); let e = &m.history()[0]; assert_eq!( e.to.as_deref(), Some("0xTO"), "a present field is never overwritten" ); + assert_eq!( + e.reason.as_deref(), + Some("broadcast failed"), + "a present reason is never overwritten" + ); assert_eq!(e.amount_wei.as_deref(), Some("5"), "a missing field fills"); assert_eq!(e.chain_id, Some(1)); assert_eq!(e.tx_hash.as_deref(), Some("0xfeed")); - assert_eq!(e.reason, None); + + // And the reverse direction: a poor base gains a real reason. + let mut m = Model::new(); + m.set_history(vec![history_entry("b", 10, OutcomeState::Failed)]); + let mut from = history_entry("b", 99, OutcomeState::Failed); + from.reason = Some("broadcast failed".to_owned()); + m.fill_history_detail(&from); + assert_eq!( + m.history()[0].reason.as_deref(), + Some("broadcast failed"), + "a missing reason fills with the real value" + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 7d56651..600d2b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,15 +125,23 @@ struct HistoryLog { path: Option, /// Every id in the log or recorded this session — the dedup set. seen: HashSet, + /// Appends since the last compaction — a resident session compacts + /// periodically, not only at startup (Gate-2 МИНОР-3). + appended: usize, } impl HistoryLog { - /// Resolve the path cascade (`RUSTOK_CONSOLE_LOG` → - /// `$RUSTOK_DATA_DIR/console-activity.jsonl` → session-only), load and - /// dedup the file, compact it when oversized (best-effort), and seed the - /// model with the entries and a degradation note when there is one. + /// Env wrapper over [`Self::open_at`] — kept apart so the open path is + /// testable without touching the process environment (Gate-2 МИНОР-8). fn open(model: &mut Model) -> Self { - let Some(path) = history_log_path() else { + Self::open_at(history_log_path(), model) + } + + /// Load and merge the file at `path` (or none = session-only), compact + /// it when oversized (best-effort), and seed the model with the entries + /// and a degradation note when there is one. + fn open_at(path: Option, model: &mut Model) -> Self { + let Some(path) = path else { model.set_history_note( "history is session-only — set RUSTOK_CONSOLE_LOG or RUSTOK_DATA_DIR to persist" .to_owned(), @@ -141,22 +149,34 @@ impl HistoryLog { return Self { path: None, seen: HashSet::new(), + appended: 0, }; }; - let (entries, skipped) = load_history(&path); - if skipped > 0 { + let loaded = load_history(&path); + if loaded.unreadable { + // NOT the missing-file case (that is a normal empty history): + // permissions or I/O — a history may exist but cannot be read. + // Say so instead of presenting a healthy empty view (BLOCKER-2). + model.set_history_note( + "activity log could not be read — history may be incomplete".to_owned(), + ); + } else if loaded.skipped > 0 { model.set_history_note(format!( - "activity log partially unreadable — {skipped} line(s) skipped" + "activity log partially unreadable — {} line(s) skipped", + loaded.skipped )); } - if entries.len() > 2 * HISTORY_CAP { - compact_history(&path, &entries); + // The trigger counts RAW lines, not deduped entries — duplicate junk + // must not grow unbounded below the entry count (Gate-2 МИНОР-2). + if loaded.raw_lines > 2 * HISTORY_CAP { + compact_history(&path); } - let seen = entries.iter().map(|e| e.id.clone()).collect(); - model.set_history(entries); + let seen = loaded.entries.iter().map(|e| e.id.clone()).collect(); + model.set_history(loaded.entries); Self { path: Some(path), seen, + appended: 0, } } @@ -165,26 +185,27 @@ impl HistoryLog { /// missing fields of its existing row (fill-missing merge, spec /check-2 /// — the file keeps its first record, append-only). A failed write /// switches persistence off with a visible note — never a crash, never - /// silence. + /// silence. Every [`HISTORY_CAP`] appends the file re-compacts: a + /// resident session must not grow it unboundedly until the next restart + /// (Gate-2 МИНОР-3). fn record(&mut self, model: &mut Model, entry: HistoryEntry) { if !self.seen.insert(entry.id.clone()) { - model.fill_history_detail( - &entry.id, - entry.to.as_deref(), - entry.amount_wei.as_deref(), - entry.chain_id, - entry.tx_hash.as_deref(), - entry.reason.as_deref(), - ); + model.fill_history_detail(&entry); return; } - if let Some(path) = &self.path - && append_history_line(path, &entry).is_err() - { - self.path = None; - model.set_history_note( - "history is not being persisted — the activity log write failed".to_owned(), - ); + if let Some(path) = &self.path { + if append_history_line(path, &entry).is_err() { + self.path = None; + model.set_history_note( + "history is not being persisted — the activity log write failed".to_owned(), + ); + } else { + self.appended += 1; + if self.appended >= HISTORY_CAP { + compact_history(path); + self.appended = 0; + } + } } model.push_history(entry); } @@ -202,57 +223,132 @@ fn history_log_path() -> Option { .map(|dir| Path::new(&dir).join("console-activity.jsonl")) } -/// Load the JSONL log: corrupt lines are skipped (counted, never fatal), and -/// entries are deduped by id — two consoles may have appended the same server -/// outcome concurrently (spec /check-3); the newest stamp wins. A missing -/// file is an empty history, not an error. -fn load_history(path: &Path) -> (Vec, usize) { - let Ok(text) = std::fs::read_to_string(path) else { - return (Vec::new(), 0); +/// What [`load_history`] found on disk. +struct LoadedHistory { + /// Merged entries, one per id (file order first-wins; later duplicates + /// only fill missing fields — Gate-2 BLOCKER-1). + entries: Vec, + /// Corrupt (non-UTF8 / non-JSON) lines — counted, noted, never fatal. + skipped: usize, + /// Raw non-empty lines on disk, pre-dedup — the compaction trigger. + raw_lines: usize, + /// The file exists but could NOT be read (permissions, I/O) — distinct + /// from missing (a normal empty history). BLOCKER-2: this surfaces as a + /// note, never as a silently healthy empty view. + unreadable: bool, +} + +/// Load the JSONL log as BYTES — a torn multi-byte write from a killed +/// process must poison one line, not the whole read (BLOCKER-2). Corrupt +/// lines are skipped and counted; duplicate ids merge FIELD-BY-FIELD in +/// file order (the first record wins for present fields, later ones fill +/// gaps — the same [`HistoryEntry::fill_missing_from`] seam as every other +/// duplicate path). BLOCKER-1: the stamps of a rich decision record and a +/// poor server-window record come from different formulas — a timestamp +/// must never decide which record survives whole. +fn load_history(path: &Path) -> LoadedHistory { + let empty = |unreadable| LoadedHistory { + entries: Vec::new(), + skipped: 0, + raw_lines: 0, + unreadable, + }; + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == io::ErrorKind::NotFound => return empty(false), + Err(_) => return empty(true), }; let mut skipped = 0usize; - let mut by_id: HashMap = HashMap::new(); - for line in text.lines().filter(|l| !l.trim().is_empty()) { - match serde_json::from_str::(line) { - Ok(entry) => match by_id.entry(entry.id.clone()) { - std::collections::hash_map::Entry::Occupied(mut slot) => { - if entry.unix > slot.get().unix { - slot.insert(entry); - } - } - std::collections::hash_map::Entry::Vacant(slot) => { - slot.insert(entry); - } - }, - Err(_) => skipped += 1, + let mut raw_lines = 0usize; + let mut entries: Vec = Vec::new(); + let mut index_of: HashMap = HashMap::new(); + for raw in bytes.split(|b| *b == b'\n') { + if raw.iter().all(u8::is_ascii_whitespace) { + continue; + } + raw_lines += 1; + let parsed = std::str::from_utf8(raw) + .ok() + .and_then(|line| serde_json::from_str::(line).ok()); + let Some(entry) = parsed else { + skipped += 1; + continue; + }; + match index_of.entry(entry.id.clone()) { + std::collections::hash_map::Entry::Occupied(slot) => { + entries[*slot.get()].fill_missing_from(&entry); + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entries.len()); + entries.push(entry); + } } } - (by_id.into_values().collect(), skipped) + LoadedHistory { + entries, + skipped, + raw_lines, + unreadable: false, + } } -/// Append one entry as a JSONL line. +/// Open (creating 0600) for writing. The mode applies only when the file is +/// created — the log carries the wallet's decision history, no reason to be +/// group/world-readable (Gate-2 МИНОР-5). +fn open_log_0600(path: &Path, append: bool) -> io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + std::fs::OpenOptions::new() + .create(true) + .append(append) + .write(true) + .truncate(!append) + .mode(0o600) + .open(path) +} + +/// Serialize log WRITERS across processes: appends and compaction take an +/// exclusive lock on a sidecar lockfile. The sidecar — not the log itself — +/// because compaction replaces the log via rename: a writer blocked on the +/// old inode's lock would wake up owning an unlinked ghost file and write +/// into the void (Gate-2 МИНОР-1 — the TOCTOU race four reviewers hit). +/// Readers do not lock: appends are line-atomic and rename is atomic. +fn lock_log(path: &Path) -> io::Result { + let mut lock_path = path.as_os_str().to_owned(); + lock_path.push(".lock"); + let file = open_log_0600(Path::new(&lock_path), true)?; + file.lock()?; + Ok(file) // released on drop +} + +/// Append one entry as a JSONL line, under the writers' lock. fn append_history_line(path: &Path, entry: &HistoryEntry) -> io::Result<()> { let mut line = serde_json::to_string(entry).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; line.push('\n'); - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)? - .write_all(line.as_bytes()) + let _lock = lock_log(path)?; + open_log_0600(path, true)?.write_all(line.as_bytes()) } -/// Best-effort compaction: rewrite the newest [`HISTORY_CAP`] entries -/// atomically (tmp beside the file + rename, same filesystem) — corrupt and -/// duplicate lines are dropped with the rest (the file heals itself). Called -/// only when the loaded file carried more than 2× the cap; any failure -/// leaves the file exactly as it was. -fn compact_history(path: &Path, entries: &[HistoryEntry]) { - let mut newest: Vec<&HistoryEntry> = entries.iter().collect(); +/// Best-effort compaction: under the writers' lock, RE-READ the file fresh — +/// a snapshot taken before the lock could erase a concurrent append (Gate-2 +/// МИНОР-1) — keep the newest [`HISTORY_CAP`] merged entries, and swap them +/// in atomically. The tmp name carries the pid (МИНОР-4: a shared name +/// would race two consoles starting over the same oversized file). Corrupt +/// and duplicate lines are dropped with the rest (the file heals); any +/// failure leaves the file exactly as it was. +fn compact_history(path: &Path) { + let Ok(_lock) = lock_log(path) else { + return; + }; + let loaded = load_history(path); + if loaded.unreadable { + return; + } + let mut newest = loaded.entries; newest.sort_unstable_by(|a, b| b.unix.cmp(&a.unix).then_with(|| a.id.cmp(&b.id))); newest.truncate(HISTORY_CAP); let mut body = String::new(); - for entry in newest { + for entry in &newest { match serde_json::to_string(entry) { Ok(line) => { body.push_str(&line); @@ -261,8 +357,11 @@ fn compact_history(path: &Path, entries: &[HistoryEntry]) { Err(_) => return, } } - let tmp = path.with_extension("jsonl.tmp"); - if std::fs::write(&tmp, body).is_ok() && std::fs::rename(&tmp, path).is_err() { + let tmp = path.with_extension(format!("jsonl.tmp.{}", std::process::id())); + let written = open_log_0600(&tmp, false) + .and_then(|mut f| f.write_all(body.as_bytes())) + .is_ok(); + if written && std::fs::rename(&tmp, path).is_err() { let _ = std::fs::remove_file(&tmp); } } @@ -1084,6 +1183,14 @@ mod tests { map_key(&key(KeyCode::Char('a')), &activitying()), Some(Msg::View(View::Queue)) )); + assert!(matches!( + map_key(&key(KeyCode::Char('d')), &activitying()), + Some(Msg::View(View::Dashboard)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('r')), &activitying()), + Some(Msg::View(View::Receive)) + )); assert!(matches!( map_key(&key(KeyCode::Char('q')), &activitying()), Some(Msg::Quit) @@ -1113,6 +1220,67 @@ mod tests { } } + #[test] + fn load_merges_duplicate_ids_field_by_field_never_by_timestamp() { + // Gate-2 BLOCKER-1: console P1 decides a card (rich record, unix = + // now); a second long-lived console P2 later sees the same id via + // activity polling (poor record, unix = arrival − age — a DIFFERENT + // formula, not guaranteed smaller). Both lines are in the file. A + // whole-record replace by "bigger unix" trades the rich record for + // the poor one; the load must merge field-by-field instead. + let path = tmp_log("merge_rich_first"); + let mut rich = hist("a", 100); + rich.to = Some("0xTO".to_owned()); + rich.amount_wei = Some("5".to_owned()); + rich.tx_hash = Some("0xfeed".to_owned()); + append_history_line(&path, &rich).expect("append rich"); + append_history_line(&path, &hist("a", 200)).expect("append poor, newer stamp"); + + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), 1); + assert_eq!( + loaded.entries[0].to.as_deref(), + Some("0xTO"), + "rich details survive a poorer duplicate with a newer stamp" + ); + assert_eq!(loaded.entries[0].tx_hash.as_deref(), Some("0xfeed")); + + // The reverse file order merges too: a poor base gains the rich fields. + let path = tmp_log("merge_poor_first"); + append_history_line(&path, &hist("b", 200)).expect("append poor"); + let mut rich = hist("b", 100); + rich.to = Some("0xTO".to_owned()); + append_history_line(&path, &rich).expect("append rich"); + let loaded = load_history(&path); + assert_eq!( + loaded.entries[0].to.as_deref(), + Some("0xTO"), + "a later rich line fills the poor base's gaps" + ); + } + + #[test] + fn a_torn_non_utf8_tail_never_hides_the_healthy_lines() { + // Gate-2 BLOCKER-2: a process killed mid-append (SIGKILL on the + // container) leaves a torn, possibly non-UTF8 tail. That is line + // corruption — counted and noted — never "the whole history is gone". + let path = tmp_log("torn"); + append_history_line(&path, &hist("good", 10)).expect("append"); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(b"\xff\xfe{torn")) + .expect("torn tail"); + + let loaded = load_history(&path); + assert_eq!( + loaded.entries.len(), + 1, + "the good line must survive a torn tail" + ); + assert_eq!(loaded.skipped, 1, "the torn tail is counted as corruption"); + } + #[test] fn the_log_roundtrips_and_a_duplicate_id_keeps_the_newest_stamp() { let path = tmp_log("roundtrip"); @@ -1122,11 +1290,19 @@ mod tests { // outcome with a newer stamp (spec /check-3). append_history_line(&path, &hist("a", 30)).expect("append"); - let (entries, skipped) = load_history(&path); - assert_eq!(skipped, 0); - assert_eq!(entries.len(), 2, "load dedups by id"); - let a = entries.iter().find(|e| e.id == "a").expect("a survives"); - assert_eq!(a.unix, 30, "the newest stamp wins the dedup"); + let loaded = load_history(&path); + assert_eq!(loaded.skipped, 0); + assert_eq!(loaded.entries.len(), 2, "load dedups by id"); + assert_eq!(loaded.raw_lines, 3, "the raw line count sees every line"); + let a = loaded + .entries + .iter() + .find(|e| e.id == "a") + .expect("a survives"); + assert_eq!( + a.unix, 10, + "the FIRST record in file order stays the base (never a timestamp contest)" + ); } #[test] @@ -1139,11 +1315,60 @@ mod tests { .and_then(|mut f| f.write_all(b"{not json\ntail-without-brace")) .expect("hostile bytes"); - let (entries, skipped) = load_history(&path); - assert_eq!(entries.len(), 1, "the good line survives"); - assert_eq!(skipped, 2, "both hostile lines are counted, not fatal"); - // A missing file is an empty history, not an error. - assert_eq!(load_history(Path::new("/nonexistent/x.jsonl")), (vec![], 0)); + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), 1, "the good line survives"); + assert_eq!( + loaded.skipped, 2, + "both hostile lines are counted, not fatal" + ); + // A missing file is a normal empty history — NOT the unreadable case. + let missing = load_history(Path::new("/nonexistent/x.jsonl")); + assert!(missing.entries.is_empty() && !missing.unreadable); + } + + #[test] + fn an_unreadable_file_is_flagged_never_a_healthy_empty_history() { + // Gate-2 BLOCKER-2: permissions/I/O trouble is NOT "no history yet". + let path = tmp_log("unreadable"); + append_history_line(&path, &hist("a", 10)).expect("append"); + let mut perms = std::fs::metadata(&path).expect("meta").permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o000); + std::fs::set_permissions(&path, perms).expect("chmod"); + + let loaded = load_history(&path); + assert!(loaded.unreadable, "a read failure must be flagged"); + + let mut model = Model::new(); + let log = HistoryLog::open_at(Some(path.clone()), &mut model); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("could not be read")), + "open surfaces the unreadable log as a note" + ); + assert!(log.path.is_some(), "appends stay best-effort"); + } + + #[test] + fn open_at_none_is_session_only_and_the_log_file_is_private() { + let mut model = Model::new(); + let log = HistoryLog::open_at(None, &mut model); + assert!(log.path.is_none()); + assert!( + model + .history_note() + .is_some_and(|n| n.contains("session-only")), + "no path configured reads as an explicit note" + ); + + // МИНОР-5: a fresh log file is created 0600 — the wallet's decision + // history is nobody else's business. + let path = tmp_log("private"); + append_history_line(&path, &hist("a", 10)).expect("append"); + let mode = std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&path).expect("meta").permissions(), + ); + assert_eq!(mode & 0o777, 0o600, "the log is owner-only"); } #[test] @@ -1158,19 +1383,24 @@ mod tests { body.push_str("{corrupt line\n"); std::fs::write(&path, body).expect("seed file"); - let (entries, skipped) = load_history(&path); - assert_eq!(entries.len(), total); - assert_eq!(skipped, 1); - compact_history(&path, &entries); + let loaded = load_history(&path); + assert_eq!(loaded.entries.len(), total); + assert_eq!(loaded.skipped, 1); + compact_history(&path); - let (after, skipped_after) = load_history(&path); - assert_eq!(after.len(), HISTORY_CAP, "compaction keeps exactly the cap"); + let after = load_history(&path); assert_eq!( - skipped_after, 0, + after.entries.len(), + HISTORY_CAP, + "compaction keeps exactly the cap" + ); + assert_eq!( + after.skipped, 0, "the corrupt line is gone — the file healed" ); assert!( after + .entries .iter() .all(|e| e.unix > (total - 1 - HISTORY_CAP) as u64), "what was dropped is exactly the oldest tail" @@ -1184,6 +1414,7 @@ mod tests { let mut log = HistoryLog { path: Some(path.clone()), seen: HashSet::new(), + appended: 0, }; log.record(&mut model, hist("a", 10)); @@ -1199,9 +1430,42 @@ mod tests { Some("0xfeed"), "the missing detail filled in memory" ); - let (on_disk, _) = load_history(&path); - assert_eq!(on_disk.len(), 1, "the file keeps its first record only"); - assert_eq!(on_disk[0].tx_hash, None); + let on_disk = load_history(&path); + assert_eq!( + on_disk.entries.len(), + 1, + "the file keeps its first record only" + ); + assert_eq!(on_disk.entries[0].tx_hash, None); + } + + #[test] + fn a_resident_session_compacts_periodically_not_only_at_startup() { + // Gate-2 МИНОР-3: seed a file bloated with duplicate lines of one id, + // arm the append counter one short of the threshold — the very next + // record() must fire a compaction that shrinks the file. + let path = tmp_log("periodic"); + let mut body = String::new(); + for i in 0..600u64 { + body.push_str(&serde_json::to_string(&hist("dup", i)).unwrap()); + body.push('\n'); + } + std::fs::write(&path, body).expect("seed bloated file"); + + let mut model = Model::new(); + let mut log = HistoryLog { + path: Some(path.clone()), + seen: std::iter::once("dup".to_owned()).collect(), + appended: HISTORY_CAP - 1, + }; + log.record(&mut model, hist("fresh", 1000)); + + let after = load_history(&path); + assert_eq!( + after.raw_lines, 2, + "the mid-session compaction collapsed the duplicate bloat" + ); + assert_eq!(log.appended, 0, "the counter re-arms after compacting"); } #[test] @@ -1210,6 +1474,7 @@ mod tests { let mut log = HistoryLog { path: Some(PathBuf::from("/nonexistent-dir/x.jsonl")), seen: HashSet::new(), + appended: 0, }; log.record(&mut model, hist("a", 10)); assert!( diff --git a/src/ui.rs b/src/ui.rs index 0e18246..3773e94 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2369,26 +2369,39 @@ mod tests { } #[test] - fn the_outcome_word_carries_its_semantic_color() { + fn every_outcome_word_carries_its_semantic_color() { + // Gate-2 НИТ: all four states, not just Denied. use ratatui::backend::TestBackend; - let m = on_activity(vec![history("d1", NOW - 5, OutcomeState::Denied)]); + let m = on_activity(vec![ + history("e1", NOW - 2, OutcomeState::Executed), + history("d1", NOW - 5, OutcomeState::Denied), + history("x1", NOW - 9, OutcomeState::Expired), + history("f1", NOW - 13, OutcomeState::Failed), + ]); let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| render(f, &m, NOW)).unwrap(); let buffer = terminal.backend().buffer(); - let mut found = false; - for y in 0..24u16 { - let row: String = (0..80).map(|x| buffer[(x, y)].symbol()).collect(); - if let Some(at) = row.find("rejected") { - assert_eq!( - buffer[(u16::try_from(at).unwrap(), y)].style().fg, - Some(theme::reject()), - "a rejection reads in the reject color" - ); - found = true; + for (word, color) in [ + ("approved", theme::approve()), + ("rejected", theme::reject()), + ("expired", theme::high_risk()), + ("failed", theme::reject()), + ] { + let mut found = false; + for y in 0..24u16 { + let row: String = (0..80).map(|x| buffer[(x, y)].symbol()).collect(); + if let Some(at) = row.find(word) { + assert_eq!( + buffer[(u16::try_from(at).unwrap(), y)].style().fg, + Some(color), + "{word} reads in its semantic color" + ); + found = true; + } } + assert!(found, "the {word} row renders"); } - assert!(found, "the rejected row renders"); } #[test] @@ -2438,4 +2451,25 @@ mod tests { "log degradation is visible on the view: {rows:?}" ); } + + #[test] + fn the_note_and_the_overflow_marker_share_the_budget() { + // Gate-2 НИТ: the note reserves its row BEFORE the budget is spent — + // 21 inner rows − header − note = 19 → 18 rows + "+3 more", and the + // note still visible under the marker. + let over: Vec = (0u64..21) + .map(|i| history(&format!("e{i:02}"), NOW - i, OutcomeState::Denied)) + .collect(); + let mut m = on_activity(over); + m.set_history_note("history is session-only".to_owned()); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["+3 more"]), + "the note's reserved row shrinks the list budget by one: {rows:?}" + ); + assert!( + has_line_with(&rows, &["session-only"]), + "the note survives an overflowing list: {rows:?}" + ); + } } From f52298e1896c1125ad17d4f64f872c2553568b17 Mon Sep 17 00:00:00 2001 From: temrjan Date: Tue, 14 Jul 2026 16:00:24 +0500 Subject: [PATCH 6/6] docs: engineer report + Gate-2 delta for Stage 7 --- ...026-07-14-console-v2-phase2-s7-activity.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 .claude/reports/2026-07-14-console-v2-phase2-s7-activity.md diff --git a/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md b/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md new file mode 100644 index 0000000..8916902 --- /dev/null +++ b/.claude/reports/2026-07-14-console-v2-phase2-s7-activity.md @@ -0,0 +1,197 @@ +# Отчёт: Console v2 Фаза 2, Этап 7 — вид Activity + локальный лог (ПОСЛЕДНИЙ этап) + +> Инженер → Капитану (для Ревьюера, Гейт-2). Спека: +> `.claude/specs/2026-07-14-console-v2-phase2-s7-activity.md` (Гейт-1: обе подписи, находка +> Ревьюера про id-tiebreak вшита в тест-план). Ветка `feat/console-v2-phase2-s7-activity` +> (НЕ запушена): `805a9b4` format → `dc99d99` protocol → `6682dab` app/transport/ui/main → +> `4c94e62` спека+смоук. core — ноль изменений (op готов, core#93). + +## Сделано — по пунктам спеки + +1. **format.rs**: `short_addr` возвращён (первые 6 + последние 4, EIP-55 регистр verbatim; + не-ASCII/короткое/без префикса — как есть). Только для Activity-списка. +2. **protocol.rs**: `Request::Activity` + `OutcomeEntry` + `parse_activity` (зеркало + positions). **`OutcomeState` — свой enum из 4 слов, осознанно НЕ `TerminalState`**: + §3.9 обещает «never pending» → «pending» в ответе валит парс (default-deny, тест). + Ошибок-деграций нет: любые `ok:false` → `Unexpected` → Fatal (словарь §3.9). +3. **transport.rs**: `Request::Activity`/`Reply::Activity` + 2 арма диспетча. +4. **app.rs — шедулер**: `dispatch_read_op` выбирает op по view: Dashboard — чередование + Context/Positions (двухвалентный `ReadOp` НЕ тронут — уход/возврат возобновляет + чередование с того же места, тест), Activity-вью — `activity` на том же list-first + слоте, та же STALE_TICKS-каденция; вход в Activity метит стейл (зеркало Dashboard). + Все гейты слота (flush-первым, тройная чистота, тик-не-рождает) — без изменений. +5. **app.rs — история**: `HistoryEntry` (JSONL-форма = display-форма, unknown-поля + игнорятся) + `HistoryFilter` (`f`-цикл, гейт на view в модели) + чистый + `Vec` (sort unix desc + id asc, кап 500). **У модели нет часов и ФС**: rich-запись + рождается в `apply_resolve`, пока карточка жива (to/amount/chain оттуда), и дренится + `take_decided_outcome()`; server-исходы дренятся `take_server_outcomes()`. + `fill_history_detail` добирает ТОЛЬКО пустые поля (в обе стороны: tx_hash с сервера + к rich-записи и to/amount к server-записи), присутствующее не перезаписывается. + **`Decision`/`decision_line` не тронуты** (ADR #7) — отдельный канал, доказано тестом + и смоуком (ровно одна JSON-строка на piped stdout). +6. **main.rs**: `HistoryLog` (путь-каскад `RUSTOK_CONSOLE_LOG` → + `$RUSTOK_DATA_DIR/console-activity.jsonl` → session-only с нотой; dedup-set; + `record()` = новый id → append+push, знакомый → fill-missing без второй строки в + файле); `load_history` (битые строки скип+счёт, дедуп по id newest-wins — две + конкурентные консоли, /check-3); компакция >1000 валидных → newest 500 атомарно + (tmp+rename, битое выбрасывается — файл самочинится); отказ записи → session-only + + видимая нота, не падение. Дренажи в цикле: stamp (`now` для решения, + `arrival − age` для server-исхода, §3.9) → append → push — один проход, до рендера. + Ключи: `h` из трёх видов; в Activity: `a`/Esc/`d`/`r`/`f`/`q`. +7. **ui.rs**: 4-я вкладка `Activity [h]`; `render_activity` — фильтр-шапка, строки + newest-first (rich: возраст·исход·сумма→short_addr·tx; server-only: честное + «(details not recorded)»), пустые состояния, overflow по уроку Этапа 5 + (exact-fit/маркер «+K more», бюджет считается один раз), футер-нота деградации + лога; семантические цвета исходов (fg-ассерт). + +## Критерии приёмки — все закрыты + +| # | Критерий | Статус | +|---|---|---| +| 1 | red→green на каждый кусок | ✅ 32 новых теста, 12 red-пруфов (11 мутаций + 1 true-red литерала теста) | +| 2 | Лог переживает retention | ✅ смоук: run 2 при ПУСТОМ server-окне показывает всё из файла | +| 3 | Инварианты §4 | ✅ short_addr только в списке (смоук: полного адреса НЕТ в кадре); карточка/Receive/fit-гейт не тронуты; в логе только публичные поля исхода | +| 4 | Порядок цикла нормативен | ✅ дренаж→stamp→append→push в одном проходе (код + смоук «мгновенная rich-строка») | +| 5 | decision_line байт-в-байт | ✅ существующие тесты Decision без правок; смоук: ровно 1 строка ADR #7 | +| 6 | Ноль правок существующих тестов | ✅ подтверждено: 184 старых лib-теста нетронуты (/check-4 сошёлся — substring-таб-ассерты пережили вкладку) | +| 7 | Точные CI-команды | ✅ см. ниже | +| 8 | DoD-смоук | ✅ **11/11 PASS** | + +## Как тестировал + +Гейты (все 4 CI-джоба дословно): `cargo fmt --all --check` чисто · +`cargo clippy --all-targets -- -D warnings` чисто · `cargo test` — **210+25+1** +(было 184+19+1: +26 lib, +6 main) · `cargo deny check advisories licenses bans sources` ok×4. + +**Red-пруфы — каждый ронял ровно свой тест** (все откатаны, дерево = коммиты): +protocol: «Pending прокрался в OutcomeState» · «порядок перевёрнут в парсе»; +format: «хвост 4→3»; шедулер: «Activity-вью шлёт Positions» · «вход не метит стейл» · +«activity двигает чередование»; история: «свап армов outcome_state» · «fill перезаписывает +присутствующее» · «сорт oldest-first»; main: «record не дедупит» · «load-дедуп oldest-wins»; +ui: «резерв маркера снят» (класс блокера Этапа 5). Плюс true-red: тест rich-строки падал +на моём неверном литерале (`0xfeedf…` вместо `0xfeedfe…`) — чинился ТЕСТ, код был прав. + +**DoD-смоук** (`.claude/reports/2026-07-14-smoke-activity.py`, реконструктор экрана вместо +substring по сырому ANSI — ratatui диффит кадры): реальный бинарь в pty против proto-2 +стаба, два прогона над одним лог-файлом — вход→h→server-строки→фильтр→решение по +карточке→**rich-строка мгновенно**→короткий адрес (полного НЕТ в кадре)→ровно одна +decision-строка на piped stdout→лог 3 исхода на диске (rich с to/tx)→**перезапуск +подхватывает историю при пустом server-окне**→exit 6×2. 11/11. + +## Не сделано / отложено (по спеке) + +Dashboard-activity-строка (реш. №1, отдельный мини-круг) · скролл (реш. №3) · +core/канон — ноль · README-строка про `RUSTOK_CONSOLE_LOG` — релизный поезд §8. + +## Отклонения от плана + +1. **(нейтральное, поимённо)** Спека называла `ReadOp::Activity` — вариант НЕ добавлен: + activity не участвует в чередовании (op выбирается по view), двухвалентное состояние + `ReadOp` не тронуто — уход/возврат на Dashboard возобновляет чередование с того же + места (тест `activity_never_joins_the_dashboard_alternation`). Буква изменена, цель + гейта (view-выбор op на том же слоте) — точно по спеке. +2. **(в плюс)** Смоук-ассерты переписаны с сырого ANSI-потока на реконструированный экран + (30-строчный парсер CSI-H): ratatui диффит кадры — устоявшийся экран не эмитит байтов, + слова рвутся курсор-джампами. Урок в отчёт: substring по pty-потоку валиден только на + переходных кадрах. + +## Замечено, не трогаю + +1. MoveUp/Down двигают невидимый queue-selected на не-Queue видах — существующее, безвредно. +2. ТЗ §2-таблица: Dashboard-данные числят activity — отметить «бэклог» при правке ТЗ на закрытии. +3. `.claude/reports/__pycache__` появлялся от прогона смоука — прибран; в .gitignore console + не входит (одна строка при случае, не этот PR). + +## Вопросы Ревьюеру + +1. `HistoryLog::record` на знакомом id НЕ пишет коррективу в файл (append-only; fill только + в памяти) — файл в углу «решил не я» остаётся без tx_hash. Спека это фиксирует как цену + угла; подтверди, что цена принята (альтернатива — корректива-строка с тем же id, + load-дедуп newest-wins её подхватит: +3 строки кода, но файл двоит записи). + **[Закрыт вердиктом: цена принята как описано.]** +2. Смоук-таймауты (3.5 с на полл-цикл) — на медленном CI не гоняется (смоук локальный, + как у Этапов 3/5); флейк-риска в юнитах нет (все синхронные). **[Закрыт: согласен.]** + +--- + +# ДЕЛЬТА — раунд фиксов Гейта-2 (2026-07-14, `718c594`) + +## БЛОКЕР-1 — закрыт, TRUE-RED в обе стороны + +`load_history` теперь мержит дубликаты id **field-by-field в порядке файла** (первая запись +— база, поздние только заполняют пустое) через **единый шов +`HistoryEntry::fill_missing_from`** — тот же, что у внутрисессионного fill. Целиковой +замены по unix больше нет — штампы rich (`now` в момент решения) и poor +(`arrival − age`) из разных формул и сравнению не подлежат. + +**TRUE-RED пруф:** тест `load_merges_duplicate_ids_field_by_field_never_by_timestamp` +(rich unix=100 → poor unix=200 тем же id; и обратный порядок файла) прогнан ПРОТИВ +нефиксённого кода — **FAILED с `left: None`** (rich-детали реально терялись), после фикса +зелёный в обе стороны. + +Заодно МИНОР-9: `fill_history_detail(&HistoryEntry)` вместо пяти позиционных Option +(свап-риск снят типом), МИНОР-6: fill-тест кроет все 5 полей в обе стороны, включая +reason с реальным значением. + +## БЛОКЕР-2 — закрыт, TRUE-RED + +`load_history` читает **байты**: порванная не-UTF8 запись (SIGKILL посреди append) +травит одну строку (скип+счёт+нота), а не весь файл; `NotFound` (нормальная пустая +история) отличён от **unreadable** (права/I/O) — последний поднимает ноту «activity log +could not be read — history may be incomplete», никогда не выглядит здоровой пустотой. + +**TRUE-RED пруфы:** `a_torn_non_utf8_tail_never_hides_the_healthy_lines` — FAILED на +нефиксённом коде (весь файл прятался); `an_unreadable_file_is_flagged…` (chmod 000) — +red мутацией «unreadable→empty». + +## МИНОРы — все в раунде + +1. **TOCTOU компакции**: писатели (append И компакция) сериализуются эксклюзивным локом + на **sidecar-lockfile** (`.lock`, std `File::lock`, снимается на drop). Sidecar, + а не сам лог — rename компакции оставил бы ждущего писателя с локом на unlinked-иноде + (запись в пустоту). Компакция **перечитывает файл заново под локом** — снэпшот до + лока не может стереть конкурентный аппенд. Читатели не локаются (аппенды + построчно-атомарны, rename атомарен). +2. **Триггер по сырым строкам** (`raw_lines`, не дедупнутым записям) — дублирующий мусор + не растёт мимо триггера; ассерт в roundtrip-тесте. +3. **Периодическая компакция**: каждые `HISTORY_CAP` аппендов резидентной сессии + (тест: раздутый дубликатами файл + взведённый счётчик → один record() сжимает файл + 601→2 строки, счётчик перевзводится). +4. **tmp уникален по PID** (`.jsonl.tmp.`). +5. **0600** на лог/лок/tmp (`OpenOptionsExt::mode`; тест ассертит mode; мутация 0644 + ловится). +6. §МИНОР-7 (слабый дедуп-тест) — усилился фиксом БЛОКЕРа-1 (формы записей различны); + roundtrip-тест переписан: «первая запись файла — база, не конкурс таймстампов». +7. **open_at/каскад тестируемы без env** (МИНОР-8): `open()` = тонкая env-обёртка над + `open_at(Option)`; тесты open_at: None → session-only-нота; unreadable → + нота + appends best-effort. Сам env-каскад (6 строк read-only var()) остался без + юнита — покрыт смоуком (RUSTOK_CONSOLE_LOG в env стаба). + +## НИТы + +Взяты: цвет исходов ×4 состояния (был только Denied) · overflow+нота вместе (нота +резервирует строку до бюджета — «+3 more» и нота видны одновременно) · граница +short_addr ровно 13 hex · ключи d/r из Activity. Отклонён с причиной: раздельная +бюджетная дисциплина пустой/note-ветки — косметика без паники (слова Ревьюера), +поведение уже покрыто note+overflow-тестом. + +## Не тестировано (честно) + +Кросс-процессная гонка лока (два живых процесса) — юнитом не воспроизводится; логика +доказана конструкцией (sidecar + re-read под локом) и smoke двумя последовательными +процессами. Env-каскад — см. МИНОР-8 выше. + +## Гейты финального состояния + +fmt чисто · clippy `-D warnings` чисто · `cargo test` **211+30+1** (+5 тестов раунда) · +deny ok×4 · **смоук 11/11 PASS** после переписанного I/O (локи прозрачны для сценария). +Red-пруфы раунда: 3 TRUE-RED (оба БЛОКЕРа, обе стороны merge) + 3 мутации +(unreadable→empty · 0644 · fill-перезапись) — каждая роняла ровно свой тест; всё +откатано, дерево = коммиты. + +## Флаг Капитану (решение не моё и не Ревьюера) + +Целостность лога от same-uid-подделки (сфабрикованная запись с новым id): 0600 сделан; +вопрос «защищать ли журнал от подделки соседом по uid» — отдельный, рекомендация +Ревьюера — решить осознанно ПОСЛЕ этого PR (первая персистентность в кодовой базе). +Прошу слово Капитана: бэклог-issue или снять.