Skip to content

Commit 03e162f

Browse files
authored
fix(tui): clear durable overlay and distinguish gate-rejection status (#6053)
durable::render never cleared its Rect before drawing, so leftover glyphs from whatever widget rendered into the same sidebar slot earlier in the frame bled through as stray characters after the status message. Also replace the available: bool snapshot field with a DurableStatus enum so an encryption_gate (INV-8) rejection renders a distinct message instead of the generic unavailable state.
1 parent f0a9e90 commit 03e162f

3 files changed

Lines changed: 257 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2424
journal URL is also treated as shared automatically, as defense in depth. The TUI durable panel
2525
poller (`durable_poll_task`, feature `tui`) now evaluates the same policy before opening the
2626
journal — previously it bypassed the gate entirely, so the TUI panel could render a journal the
27-
`zeph durable` CLI refused to open; a rejection now degrades gracefully to the panel's existing
28-
"non-durable mode" state instead of erroring.
27+
`zeph durable` CLI refused to open; a rejection now degrades gracefully to a distinct
28+
`GateRejected` panel status instead of erroring (see #6041 below for why it no longer reuses
29+
the plain "non-durable mode" state).
2930
- `zeph-commands`: 19 privileged slash-command handlers now override `requires_auth()` to
3031
return `true`, closing a trust-gate gap where they ran the default `false` and were therefore
3132
reachable from untrusted remote channels (Telegram/Discord/Slack) as well as trusted local
@@ -88,6 +89,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8889

8990
### Fixed
9091

92+
- `fix(tui)`: the durable executions overlay panel (`D` key) no longer bleeds stray glyphs from
93+
whatever widget last drew into the same sidebar `Rect` earlier in the frame (e.g. a trailing
94+
`e> t` fragment left over from the subagents/plan/security view rendered underneath) — `Clear`
95+
was missing before the panel's own draw calls, unlike the other 8 overlay widgets in this
96+
crate that already blank their area first (#6048). Also, `encryption_gate` (INV-8) rejecting
97+
the deployment's configuration is now visually distinguishable from a plain "journal
98+
unavailable" state: the panel previously collapsed both into the identical
99+
`STATUS_UNAVAILABLE` message, so an operator had no way to tell a security-policy rejection
100+
from durable execution simply being unconfigured; `DurableSnapshot.available: bool` is
101+
replaced with a `DurableStatus` enum (`Unavailable` / `GateRejected` / `Available`) and gate
102+
rejections now render the distinct `STATUS_GATE_REJECTED` message (#6041).
91103
- `fix(core,acp,tools)`: three more per-turn behavioral pipelines/executors that were
92104
constructed and wired into the `Agent` only in `src/runner.rs` (CLI/TUI) now reach ACP,
93105
daemon (A2A), and `serve-sessions` too — same "wire-X-into-ACP/daemon/serve" defect class as

crates/zeph-tui/src/widgets/durable.rs

Lines changed: 191 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@
77
//! never payload bytes. Data arrives as a [`DurableSnapshot`] via
88
//! [`AgentEvent::DurableSnapshot`](crate::AgentEvent::DurableSnapshot), refreshed by the binary's
99
//! durable poll task. When the journal is unreachable (durable execution disabled or the file is
10-
//! missing) the panel shows the [`STATUS_UNAVAILABLE`] message.
10+
//! missing) the panel shows the [`STATUS_UNAVAILABLE`] message; when `encryption_gate` (INV-8)
11+
//! rejects the deployment's configuration it shows [`STATUS_GATE_REJECTED`] instead, so the two
12+
//! distinct causes aren't conflated (see [`DurableStatus`]).
1113
1214
use ratatui::Frame;
1315
use ratatui::layout::{Constraint, Layout, Rect};
1416
use ratatui::style::{Color, Modifier, Style};
1517
use ratatui::text::{Line, Span};
16-
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
18+
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph};
1719

1820
use crate::theme::Theme;
1921

2022
/// Status message: the journal is unreachable, so the agent runs without durability (spec-011).
2123
pub const STATUS_UNAVAILABLE: &str = "Journal unavailable — non-durable mode";
2224

25+
/// Status message: `encryption_gate` (INV-8) rejected this deployment's configuration.
26+
pub const STATUS_GATE_REJECTED: &str =
27+
"Durable journal disabled by encryption policy (INV-8) — see logs";
28+
2329
/// One durable execution, display-ready. Carries no payload bytes (INV-5 redaction).
2430
#[derive(Debug, Clone, PartialEq, Eq)]
2531
pub struct DurableRow {
@@ -35,11 +41,24 @@ pub struct DurableRow {
3541
pub age_secs: u64,
3642
}
3743

44+
/// Why the durable panel does or doesn't have live execution rows to show.
45+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46+
pub enum DurableStatus {
47+
/// Durable execution disabled, or the journal file/backend could not be opened.
48+
#[default]
49+
Unavailable,
50+
/// `encryption_gate` (INV-8) rejected this configuration (e.g. shared DB + `encrypt_payload=false`).
51+
GateRejected,
52+
/// Journal reachable; `executions` holds the live rows (possibly empty).
53+
Available,
54+
}
55+
3856
/// Snapshot of the durable journal for the panel, refreshed by the binary's poll task.
3957
#[derive(Debug, Clone, Default)]
4058
pub struct DurableSnapshot {
41-
/// Whether the journal could be reached. `false` renders [`STATUS_UNAVAILABLE`].
42-
pub available: bool,
59+
/// Why the panel is or isn't showing live rows. Anything other than [`DurableStatus::Available`]
60+
/// renders a status message instead of `executions`.
61+
pub status: DurableStatus,
4362
/// Executions, newest first.
4463
pub executions: Vec<DurableRow>,
4564
}
@@ -72,6 +91,8 @@ pub fn render(
7291
list_state: &mut ListState,
7392
theme: &Theme,
7493
) {
94+
frame.render_widget(Clear, area);
95+
7596
let exec_count = snapshot.executions.len();
7697
let header_text = format!("durable · {exec_count} [D]");
7798
let header = Line::from(Span::styled(
@@ -81,10 +102,18 @@ pub fn render(
81102
let splits = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
82103
frame.render_widget(Paragraph::new(header), splits[0]);
83104

84-
if !snapshot.available {
85-
let msg = Paragraph::new(STATUS_UNAVAILABLE).style(Style::default().fg(Color::Yellow));
86-
frame.render_widget(msg, splits[1]);
87-
return;
105+
match snapshot.status {
106+
DurableStatus::Unavailable => {
107+
let msg = Paragraph::new(STATUS_UNAVAILABLE).style(Style::default().fg(Color::Yellow));
108+
frame.render_widget(msg, splits[1]);
109+
return;
110+
}
111+
DurableStatus::GateRejected => {
112+
let msg = Paragraph::new(STATUS_GATE_REJECTED).style(Style::default().fg(Color::Red));
113+
frame.render_widget(msg, splits[1]);
114+
return;
115+
}
116+
DurableStatus::Available => {}
88117
}
89118

90119
if snapshot.executions.is_empty() {
@@ -127,6 +156,9 @@ pub fn render(
127156

128157
#[cfg(test)]
129158
mod tests {
159+
use ratatui::Terminal;
160+
use ratatui::backend::TestBackend;
161+
130162
use super::*;
131163

132164
#[test]
@@ -143,4 +175,155 @@ mod tests {
143175
assert_eq!(status_color("failed"), Color::Red);
144176
assert_eq!(status_color("mystery"), Color::White);
145177
}
178+
179+
/// Fills the whole area with a sentinel glyph before calling `render`, in the same frame —
180+
/// mirroring the real bug shape (#6048): multiple widgets drawing into the identical `Rect`
181+
/// within one `draw()` pass, where `Paragraph`/`List` only touch cells for their own content
182+
/// and leave any earlier glyph in place. Without the `Clear` call in `render`, the sentinel
183+
/// survives in every cell the status message doesn't cover; with it, none do.
184+
fn render_over_sentinel(snapshot: &DurableSnapshot) -> ratatui::buffer::Buffer {
185+
let backend = TestBackend::new(80, 10);
186+
let mut terminal = Terminal::new(backend).unwrap();
187+
let mut list_state = ListState::default();
188+
terminal
189+
.draw(|frame| {
190+
let area = frame.area();
191+
for y in area.top()..area.bottom() {
192+
for x in area.left()..area.right() {
193+
frame.buffer_mut()[(x, y)].set_symbol("#");
194+
}
195+
}
196+
render(snapshot, frame, area, &mut list_state, &Theme::default());
197+
})
198+
.unwrap();
199+
terminal.backend().buffer().clone()
200+
}
201+
202+
#[test]
203+
fn render_clears_stale_glyphs_before_drawing_unavailable_status() {
204+
let snapshot = DurableSnapshot {
205+
status: DurableStatus::Unavailable,
206+
executions: Vec::new(),
207+
};
208+
let buf = render_over_sentinel(&snapshot);
209+
for cell in &buf.content {
210+
assert_ne!(
211+
cell.symbol(),
212+
"#",
213+
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
214+
);
215+
}
216+
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
217+
assert!(
218+
rendered.contains(STATUS_UNAVAILABLE),
219+
"expected unavailable status text, got: {rendered:?}"
220+
);
221+
}
222+
223+
#[test]
224+
fn render_clears_stale_glyphs_before_drawing_gate_rejected_status() {
225+
let snapshot = DurableSnapshot {
226+
status: DurableStatus::GateRejected,
227+
executions: Vec::new(),
228+
};
229+
let buf = render_over_sentinel(&snapshot);
230+
for cell in &buf.content {
231+
assert_ne!(
232+
cell.symbol(),
233+
"#",
234+
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
235+
);
236+
}
237+
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
238+
assert!(
239+
rendered.contains(STATUS_GATE_REJECTED),
240+
"expected gate-rejected status text, got: {rendered:?}"
241+
);
242+
}
243+
244+
#[test]
245+
fn render_clears_stale_glyphs_before_drawing_available_list() {
246+
let snapshot = DurableSnapshot {
247+
status: DurableStatus::Available,
248+
executions: vec![DurableRow {
249+
id_short: "abcd1234".into(),
250+
kind: "agent_turn".into(),
251+
status: "running".into(),
252+
step_count: 3,
253+
age_secs: 12,
254+
}],
255+
};
256+
let buf = render_over_sentinel(&snapshot);
257+
for cell in &buf.content {
258+
assert_ne!(
259+
cell.symbol(),
260+
"#",
261+
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
262+
);
263+
}
264+
}
265+
266+
#[test]
267+
fn unavailable_and_gate_rejected_render_distinct_messages() {
268+
assert_ne!(STATUS_UNAVAILABLE, STATUS_GATE_REJECTED);
269+
270+
let unavailable = render_over_sentinel(&DurableSnapshot {
271+
status: DurableStatus::Unavailable,
272+
executions: Vec::new(),
273+
});
274+
let rendered_unavailable: String = unavailable
275+
.content
276+
.iter()
277+
.map(|c| c.symbol().to_owned())
278+
.collect();
279+
assert!(rendered_unavailable.contains(STATUS_UNAVAILABLE));
280+
assert!(!rendered_unavailable.contains(STATUS_GATE_REJECTED));
281+
282+
let gate_rejected = render_over_sentinel(&DurableSnapshot {
283+
status: DurableStatus::GateRejected,
284+
executions: Vec::new(),
285+
});
286+
let rendered_gate_rejected: String = gate_rejected
287+
.content
288+
.iter()
289+
.map(|c| c.symbol().to_owned())
290+
.collect();
291+
assert!(rendered_gate_rejected.contains(STATUS_GATE_REJECTED));
292+
assert!(!rendered_gate_rejected.contains(STATUS_UNAVAILABLE));
293+
}
294+
295+
#[test]
296+
fn available_status_falls_through_to_executions_list() {
297+
let snapshot = DurableSnapshot {
298+
status: DurableStatus::Available,
299+
executions: vec![DurableRow {
300+
id_short: "deadbeef".into(),
301+
kind: "dag_run".into(),
302+
status: "completed".into(),
303+
step_count: 7,
304+
age_secs: 300,
305+
}],
306+
};
307+
let buf = render_over_sentinel(&snapshot);
308+
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
309+
assert!(
310+
rendered.contains("deadbeef"),
311+
"expected execution row to render, got: {rendered:?}"
312+
);
313+
assert!(!rendered.contains(STATUS_UNAVAILABLE));
314+
assert!(!rendered.contains(STATUS_GATE_REJECTED));
315+
}
316+
317+
#[test]
318+
fn available_status_with_empty_executions_renders_placeholder() {
319+
let snapshot = DurableSnapshot {
320+
status: DurableStatus::Available,
321+
executions: Vec::new(),
322+
};
323+
let buf = render_over_sentinel(&snapshot);
324+
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
325+
assert!(rendered.contains("No durable executions recorded."));
326+
assert!(!rendered.contains(STATUS_UNAVAILABLE));
327+
assert!(!rendered.contains(STATUS_GATE_REJECTED));
328+
}
146329
}

src/tui_bridge.rs

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,45 @@ mod tests {
709709
received[0]
710710
);
711711
}
712+
713+
/// Regression for #6041: an `encryption_gate` (INV-8) rejection must produce a
714+
/// `DurableStatus::GateRejected` snapshot, not the same `Unavailable` status used for an
715+
/// ordinary failed-to-open journal — the two failure modes must stay distinguishable in the
716+
/// TUI. Uses a `postgres://`-scheme URL with `encrypt_payload = false` (default `true`) so
717+
/// `enforce_encryption_gate` rejects deterministically without needing a real Postgres server;
718+
/// the gate check runs before any backend connection is attempted.
719+
#[tokio::test]
720+
async fn durable_poll_task_sends_gate_rejected_on_encryption_gate_failure() {
721+
use zeph_tui::widgets::durable::DurableStatus;
722+
723+
let (tx, mut rx) = tokio::sync::mpsc::channel::<zeph_tui::AgentEvent>(4);
724+
let cfg = zeph_config::DurableConfig {
725+
encrypt_payload: false,
726+
..zeph_config::DurableConfig::default()
727+
};
728+
729+
durable_poll_task("postgres://user@host/db".to_owned(), cfg, tx).await;
730+
731+
let event = rx
732+
.recv()
733+
.await
734+
.expect("expected a DurableSnapshot event on gate rejection");
735+
match event {
736+
zeph_tui::AgentEvent::DurableSnapshot(snapshot) => {
737+
assert_eq!(
738+
snapshot.status,
739+
DurableStatus::GateRejected,
740+
"gate rejection must be distinguishable from a plain Unavailable snapshot"
741+
);
742+
assert!(snapshot.executions.is_empty());
743+
}
744+
other => panic!("expected DurableSnapshot event, got {other:?}"),
745+
}
746+
assert!(
747+
rx.recv().await.is_none(),
748+
"durable_poll_task must return immediately after a gate rejection, not loop"
749+
);
750+
}
712751
}
713752

714753
#[cfg(feature = "tui")]
@@ -825,25 +864,26 @@ const DURABLE_REFRESH_SECS: u64 = 5;
825864
/// `encrypt_payload = false` must be rejected here too, or the TUI panel would happily render a
826865
/// journal the CLI refuses to open — a cross-mode divergence (#5996). Unlike the CLI, this is a
827866
/// best-effort background poller with no user waiting on an error message, so a gate rejection
828-
/// degrades gracefully to the same "non-durable mode" snapshot as a failed backend open, rather
829-
/// than panicking or looping forever.
867+
/// degrades gracefully to a `GateRejected` snapshot (distinct from a failed backend open, #6041)
868+
/// rather than panicking or looping forever.
830869
#[cfg(feature = "tui")]
831870
pub(crate) async fn durable_poll_task(
832871
db_url: String,
833872
cfg: zeph_config::DurableConfig,
834873
tx: tokio::sync::mpsc::Sender<zeph_tui::AgentEvent>,
835874
) {
836-
use zeph_tui::widgets::durable::{DurableRow, DurableSnapshot};
875+
use zeph_tui::widgets::durable::{DurableRow, DurableSnapshot, DurableStatus};
837876

838877
if let Err(e) = crate::commands::durable::enforce_encryption_gate(&cfg, &db_url) {
839878
tracing::warn!(
840879
error = %e,
841-
"durable poll: encryption policy rejected this deployment; panel shows non-durable mode"
880+
"durable poll: encryption policy rejected this deployment; panel shows gate-rejected status"
842881
);
843882
let _ = tx
844-
.send(zeph_tui::AgentEvent::DurableSnapshot(
845-
DurableSnapshot::default(),
846-
))
883+
.send(zeph_tui::AgentEvent::DurableSnapshot(DurableSnapshot {
884+
status: DurableStatus::GateRejected,
885+
executions: Vec::new(),
886+
}))
847887
.await;
848888
return;
849889
}
@@ -853,9 +893,10 @@ pub(crate) async fn durable_poll_task(
853893
Err(e) => {
854894
tracing::warn!(error = %e, "durable poll: failed to open journal; panel shows non-durable mode");
855895
let _ = tx
856-
.send(zeph_tui::AgentEvent::DurableSnapshot(
857-
DurableSnapshot::default(),
858-
))
896+
.send(zeph_tui::AgentEvent::DurableSnapshot(DurableSnapshot {
897+
status: DurableStatus::Unavailable,
898+
executions: Vec::new(),
899+
}))
859900
.await;
860901
return;
861902
}
@@ -910,7 +951,7 @@ pub(crate) async fn durable_poll_task(
910951
.collect();
911952

912953
let snapshot = DurableSnapshot {
913-
available: true,
954+
status: DurableStatus::Available,
914955
executions,
915956
};
916957
if tx

0 commit comments

Comments
 (0)