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
1214use ratatui:: Frame ;
1315use ratatui:: layout:: { Constraint , Layout , Rect } ;
1416use ratatui:: style:: { Color , Modifier , Style } ;
1517use ratatui:: text:: { Line , Span } ;
16- use ratatui:: widgets:: { List , ListItem , ListState , Paragraph } ;
18+ use ratatui:: widgets:: { Clear , List , ListItem , ListState , Paragraph } ;
1719
1820use crate :: theme:: Theme ;
1921
2022/// Status message: the journal is unreachable, so the agent runs without durability (spec-011).
2123pub 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 ) ]
2531pub 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 ) ]
4058pub 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) ]
129158mod 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}
0 commit comments