diff --git a/src/main.rs b/src/main.rs index 2cfd955..914542c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,6 +110,13 @@ impl From<&window::WindowSnapshot> for WindowIdentity { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct EvidenceObservationKey { + runtime_state: RuntimeState, + commitment_id: Option, + health: EvidenceHealth, +} + struct App { state: State, user_intention: String, @@ -134,6 +141,7 @@ struct App { session_results: Vec, acknowledged_unknown_window: Option, acknowledged_unknown_window_handoff_pending: bool, + last_recorded_evidence: Option, last_tick_50ms: Instant, last_tick_1s: Instant, } @@ -225,6 +233,7 @@ impl App { session_results: Vec::new(), acknowledged_unknown_window: None, acknowledged_unknown_window_handoff_pending: false, + last_recorded_evidence: None, last_tick_50ms: now, last_tick_1s: now, }) @@ -449,7 +458,7 @@ impl App { State::ShouldQuit => {} State::Paused => { window::minimize_other(&constants::APP_TITLE); - update_session_stats(self); + update_session_stats(self)?; } State::ViolationPrompt => {} State::End => { @@ -471,7 +480,7 @@ impl App { ) -> Result<()> { let elapsed = self.session_start.elapsed(); self.session_remaining = self.user_duration.saturating_sub(elapsed); - let window_snapshot = update_session_stats_with_snapshot(self, window_snapshot); + let window_snapshot = update_session_stats_with_snapshot(self, window_snapshot)?; let should_prompt = self.state == State::InProgress && self.session_controller.runtime_state() == RuntimeState::Active @@ -546,6 +555,29 @@ impl App { Ok(()) } + fn record_evidence_if_needed(&mut self, health: &EvidenceHealth) -> Result<()> { + if matches!(health, EvidenceHealth::Available) { + self.last_recorded_evidence = None; + return Ok(()); + } + + let key = EvidenceObservationKey { + runtime_state: self.session_controller.runtime_state(), + commitment_id: self + .session_controller + .active_commitment() + .map(|commitment| commitment.id.clone()), + health: health.clone(), + }; + if self.last_recorded_evidence.as_ref() == Some(&key) { + return Ok(()); + } + + self.session_controller.record_evidence(health.clone())?; + self.last_recorded_evidence = Some(key); + Ok(()) + } + fn get_session_results(&self) -> Vec> { self.session_results .iter() @@ -866,15 +898,16 @@ fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> { Ok(()) } -fn update_session_stats(app: &mut App) -> window::WindowSnapshot { +fn update_session_stats(app: &mut App) -> Result { update_session_stats_with_snapshot(app, window::get_snapshot()) } fn update_session_stats_with_snapshot( app: &mut App, window_snapshot: window::WindowSnapshot, -) -> window::WindowSnapshot { +) -> Result { app.evidence_health = window_snapshot.health.clone(); + app.record_evidence_if_needed(&window_snapshot.health)?; let window_title = if app.state == State::Paused { constants::PAUSED.to_string() } else { @@ -894,7 +927,7 @@ fn update_session_stats_with_snapshot( app.session_ratings = session_stats_as_vec(&app.session_stats); } - window_snapshot + Ok(window_snapshot) } fn ui(frame: &mut Frame, app: &App) { @@ -1098,6 +1131,16 @@ fn ui(frame: &mut Frame, app: &App) { } } + if let Some(text) = evidence_status_text(&app.evidence_health) { + if !spans.is_empty() { + spans.push(Span::raw(" | ")); + } + spans.push(Span::styled( + text, + Style::new().fg(evidence_status_color(&app.evidence_health)), + )); + } + let input_status: Vec = vec![Line::from(spans)]; frame.render_widget( Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)), @@ -1105,6 +1148,22 @@ fn ui(frame: &mut Frame, app: &App) { ); } +fn evidence_status_text(health: &EvidenceHealth) -> Option { + match health { + EvidenceHealth::Available => None, + EvidenceHealth::Degraded(reason) => Some(format!("Evidence degraded: {}", reason)), + EvidenceHealth::Unavailable(reason) => Some(format!("Evidence unavailable: {}", reason)), + } +} + +fn evidence_status_color(health: &EvidenceHealth) -> Color { + match health { + EvidenceHealth::Available => Color::Reset, + EvidenceHealth::Degraded(_) => Color::LightYellow, + EvidenceHealth::Unavailable(_) => Color::LightRed, + } +} + fn render_violation_prompt(frame: &mut Frame, app: &App) { let layout = Layout::vertical([ Constraint::Min(5), @@ -1840,4 +1899,68 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn evidence_status_text_describes_degraded_and_unavailable_health() { + assert_eq!(evidence_status_text(&EvidenceHealth::Available), None); + assert_eq!( + evidence_status_text(&EvidenceHealth::Degraded( + "window class unavailable".to_string(), + )), + Some("Evidence degraded: window class unavailable".to_string()) + ); + assert_eq!( + evidence_status_text(&EvidenceHealth::Unavailable( + "xdotool active window unavailable".to_string(), + )), + Some("Evidence unavailable: xdotool active window unavailable".to_string()) + ); + } + + #[test] + fn active_ticks_record_degraded_evidence_once_until_health_changes() { + let path = unique_event_log_path("evidence-dedup"); + let mut app = App::new_with_event_log_path(&path).unwrap(); + + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + + let degraded = window::WindowSnapshot { + title: "Editor".to_string(), + class: None, + health: EvidenceHealth::Degraded("window class unavailable".to_string()), + }; + app.tick_active_session_with_snapshot(degraded.clone()) + .unwrap(); + app.tick_active_session_with_snapshot(degraded).unwrap(); + + let records = EventLog::open(&path).unwrap().records().unwrap(); + assert_eq!( + records + .iter() + .filter(|record| record.event_type == EventType::EvidenceObserved) + .count(), + 1 + ); + + let unavailable = window::WindowSnapshot::unavailable("xdotool active window unavailable"); + app.tick_active_session_with_snapshot(unavailable).unwrap(); + + let records = EventLog::open(&path).unwrap().records().unwrap(); + assert_eq!( + records + .iter() + .filter(|record| record.event_type == EventType::EvidenceObserved) + .count(), + 2 + ); + assert_eq!( + app.evidence_health, + EvidenceHealth::Unavailable("xdotool active window unavailable".to_string()) + ); + + let _ = fs::remove_file(path); + } } diff --git a/src/session.rs b/src/session.rs index 1147b4a..c35a029 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,6 +1,6 @@ use crate::domain::{ unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, - PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION, + EvidenceHealth, PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; use crate::state_machine::{ @@ -77,6 +77,13 @@ struct ViolationObservedPayload { evidence: Value, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EvidenceObservedPayload { + schema_version: u16, + health: EvidenceHealth, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct PendingTransitionPolicy { commitment_id: String, @@ -561,6 +568,30 @@ impl SessionController { Ok(()) } + + pub fn record_evidence(&mut self, health: EvidenceHealth) -> Result<()> { + let payload = EvidenceObservedPayload { + schema_version: POLICY_SCHEMA_VERSION, + health, + }; + validate_evidence_observed_payload(&payload)?; + let runtime_state = self.runtime_state; + let commitment_id = self + .active_commitment + .as_ref() + .map(|commitment| commitment.id.clone()); + + self.event_log + .append( + EventType::EvidenceObserved, + runtime_state, + commitment_id, + serde_json::to_value(payload).context("serialize evidence observed payload")?, + ) + .context("log evidence observation")?; + + Ok(()) + } } fn hydrate_session( @@ -914,6 +945,37 @@ fn hydrate_session( )); } } + EventType::EvidenceObserved => { + let payload: EvidenceObservedPayload = parse_payload(&record, "evidence observed")?; + ensure_schema_version(payload.schema_version, record.sequence)?; + validate_evidence_observed_payload(&payload).with_context(|| { + format!( + "validate evidence observed payload at sequence {}", + record.sequence + ) + })?; + if record.runtime_state != runtime_state { + return Err(anyhow!( + "evidence runtime mismatch at sequence {}", + record.sequence + )); + } + if let Some(commitment_id) = record.commitment_id.as_deref() { + let active = active_commitment.as_ref().ok_or_else(|| { + anyhow!( + "evidence references unknown commitment '{}' at sequence {}", + commitment_id, + record.sequence + ) + })?; + if active.id != commitment_id { + return Err(anyhow!( + "evidence commitment mismatch at sequence {}", + record.sequence + )); + } + } + } ref unsupported => { return Err(anyhow!( "unsupported session event {:?} at sequence {}", @@ -1082,6 +1144,19 @@ fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Re Ok(()) } +fn validate_evidence_observed_payload(payload: &EvidenceObservedPayload) -> Result<()> { + match &payload.health { + EvidenceHealth::Available => {} + EvidenceHealth::Degraded(reason) | EvidenceHealth::Unavailable(reason) + if reason.trim().is_empty() => + { + return Err(anyhow!("evidence reason is required")); + } + EvidenceHealth::Degraded(_) | EvidenceHealth::Unavailable(_) => {} + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ @@ -1090,7 +1165,7 @@ mod tests { }; use crate::domain::{ unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, - RuntimeState, POLICY_SCHEMA_VERSION, + EvidenceHealth, RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; use crate::session::SessionController; @@ -1317,6 +1392,156 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn records_evidence_with_health_runtime_and_active_commitment() { + let path = temp_log_path("evidence-record"); + let mut session = SessionController::new(&path).unwrap(); + + session.enter_planning().unwrap(); + session + .start_manual_commitment( + "Implement session controller", + "session tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + let commitment_id = session.active_commitment().unwrap().id.clone(); + + session + .record_evidence(EvidenceHealth::Degraded( + "window class unavailable".to_string(), + )) + .unwrap(); + + let records = read_records(&path); + let evidence = records + .iter() + .find(|record| record.event_type == EventType::EvidenceObserved) + .unwrap(); + assert_eq!(evidence.runtime_state, RuntimeState::Active); + assert_eq!( + evidence.commitment_id.as_deref(), + Some(commitment_id.as_str()) + ); + assert_eq!(evidence.payload_json["schema_version"], 1); + assert_eq!( + evidence.payload_json["health"], + json!({"degraded": "window class unavailable"}) + ); + + let reopened = SessionController::new(&path).unwrap(); + assert_eq!(reopened.runtime_state(), RuntimeState::Active); + assert_eq!( + reopened.active_commitment().unwrap().id.as_str(), + commitment_id.as_str() + ); + + fs::remove_file(path).unwrap(); + } + + #[test] + fn replay_ignores_valid_evidence_without_changing_state() { + let path = temp_log_path("evidence-replay"); + let commitment_id = "commitment-test"; + { + let mut log = EventLog::open(&path).unwrap(); + log.append_batch([ + PendingEventRecord { + event_type: EventType::RuntimeTransition, + runtime_state: RuntimeState::Planning, + commitment_id: None, + payload_json: json!({ + "schema_version": POLICY_SCHEMA_VERSION, + "action": "enter_planning", + "from": "locked", + "to": "planning", + }), + }, + PendingEventRecord { + event_type: EventType::CommitmentCreated, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: commitment_payload(commitment_id, CommitmentState::Active), + }, + PendingEventRecord { + event_type: EventType::PolicyApplied, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: policy_payload(commitment_id), + }, + PendingEventRecord { + event_type: EventType::EvidenceObserved, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: json!({ + "schema_version": POLICY_SCHEMA_VERSION, + "health": {"unavailable": "xdotool active window unavailable"}, + }), + }, + ]) + .unwrap(); + } + + let reopened = SessionController::new(&path).unwrap(); + + assert_eq!(reopened.runtime_state(), RuntimeState::Active); + assert_eq!( + reopened.active_commitment().unwrap().id.as_str(), + commitment_id + ); + fs::remove_file(path).unwrap(); + } + + #[test] + fn replay_rejects_evidence_with_empty_reason() { + let path = temp_log_path("evidence-empty-reason"); + let commitment_id = "commitment-test"; + { + let mut log = EventLog::open(&path).unwrap(); + log.append_batch([ + PendingEventRecord { + event_type: EventType::RuntimeTransition, + runtime_state: RuntimeState::Planning, + commitment_id: None, + payload_json: json!({ + "schema_version": POLICY_SCHEMA_VERSION, + "action": "enter_planning", + "from": "locked", + "to": "planning", + }), + }, + PendingEventRecord { + event_type: EventType::CommitmentCreated, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: commitment_payload(commitment_id, CommitmentState::Active), + }, + PendingEventRecord { + event_type: EventType::PolicyApplied, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: policy_payload(commitment_id), + }, + PendingEventRecord { + event_type: EventType::EvidenceObserved, + runtime_state: RuntimeState::Active, + commitment_id: Some(commitment_id.to_string()), + payload_json: json!({ + "schema_version": POLICY_SCHEMA_VERSION, + "health": {"degraded": " "}, + }), + }, + ]) + .unwrap(); + } + + let err = + SessionController::new(&path).expect_err("evidence with empty reason must be rejected"); + + assert!(error_chain_contains(&err, "evidence reason")); + fs::remove_file(path).unwrap(); + } + #[test] fn record_violation_rejects_empty_reason() { let path = temp_log_path("violation-empty-reason");