diff --git a/src/constants.rs b/src/constants.rs index bcefc4a..9ec3b33 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -29,5 +29,8 @@ pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition"; pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes"; pub const TRANSITION_REASON_TITLE: &str = "Transition Reason"; pub const TRANSITION_RETURN_TITLE: &str = "Return Target"; +pub const VIOLATION_TITLE: &str = "Violation"; +pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason"; +pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue."; pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! "; pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! "; diff --git a/src/main.rs b/src/main.rs index 9365cc7..4420f8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; // <--- Add this mod constants; use antidrift::{ - domain::{EvidenceHealth, RuntimeState}, + domain::{EvidenceHealth, PolicySnapshot, RuntimeState}, session::SessionController, window, }; @@ -37,6 +37,7 @@ enum State { InputTransitionDuration, InProgress, Paused, + ViolationPrompt, End, ShouldQuit, } @@ -102,6 +103,8 @@ struct App { transition_reason: String, transition_return_target: String, transition_duration_str: String, + violation_message: String, + violation_dismissal_reason: String, current_window_title: Rc, evidence_health: EvidenceHealth, session_controller: SessionController, @@ -188,6 +191,8 @@ impl App { transition_reason: String::new(), transition_return_target: String::new(), transition_duration_str: "2".to_string(), + violation_message: String::new(), + violation_dismissal_reason: String::new(), current_window_title: window_snapshot.title.into(), evidence_health: window_snapshot.health, session_controller, @@ -288,6 +293,30 @@ impl App { Ok(()) } + fn enter_violation_prompt(&mut self, window_snapshot: &window::WindowSnapshot) { + self.violation_message = + format!("{}: {}", constants::VIOLATION_STATUS, window_snapshot.title); + self.violation_dismissal_reason.clear(); + self.state = State::ViolationPrompt; + } + + fn dismiss_violation_if_valid(&mut self) -> Result<()> { + if self.violation_dismissal_reason.trim().is_empty() { + return Ok(()); + } + + self.session_controller.record_violation( + self.violation_dismissal_reason.clone(), + serde_json::json!({ + "message": self.violation_message, + "window_title": self.current_window_title.as_ref(), + "evidence_health": self.evidence_health, + }), + )?; + self.state = State::InProgress; + Ok(()) + } + fn to_end(&mut self) -> Result<()> { match self.session_controller.runtime_state() { RuntimeState::Transition => { @@ -354,6 +383,7 @@ impl App { constants::STATUS_PAUSED, duration_as_str(&self.session_remaining) ), + State::ViolationPrompt => constants::VIOLATION_STATUS.to_string(), State::End => constants::STATUS_RATE_SESSION.to_string(), State::ShouldQuit => constants::STATUS_QUIT.to_string(), } @@ -384,6 +414,7 @@ impl App { window::minimize_other(&constants::APP_TITLE); update_session_stats(self); } + State::ViolationPrompt => {} State::End => { window::minimize_other(&constants::APP_TITLE); } @@ -396,7 +427,18 @@ impl App { fn tick_active_session(&mut self) -> Result<()> { let elapsed = self.session_start.elapsed(); self.session_remaining = self.user_duration.saturating_sub(elapsed); - update_session_stats(self); + let window_snapshot = update_session_stats(self); + + if self.state == State::InProgress + && self.session_controller.runtime_state() == RuntimeState::Active + && self + .session_controller + .active_policy() + .is_some_and(|policy| should_prompt_for_unknown_window(policy, &window_snapshot)) + { + self.enter_violation_prompt(&window_snapshot); + return Ok(()); + } if self.session_remaining.is_zero() { self.to_end()?; @@ -507,6 +549,32 @@ fn is_transition_input_state(state: &State) -> bool { ) } +fn should_prompt_for_unknown_window( + policy: &PolicySnapshot, + window_snapshot: &window::WindowSnapshot, +) -> bool { + let allowed_context = &policy.allowed_context; + if allowed_context.window_title_substrings.is_empty() + && allowed_context.window_classes.is_empty() + { + return false; + } + + let title = window_snapshot.title.to_lowercase(); + let title_matches = allowed_context + .window_title_substrings + .iter() + .any(|allowed| title.contains(&allowed.to_lowercase())); + let class_matches = window_snapshot.class.as_ref().is_some_and(|class| { + allowed_context + .window_classes + .iter() + .any(|allowed| class.eq_ignore_ascii_case(allowed)) + }); + + !(title_matches || class_matches) +} + fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec { let mut stats: Vec<_> = session_stats .iter() @@ -656,6 +724,16 @@ fn handle_events(app: &mut App) -> Result<()> { app.resume()?; } } + State::ViolationPrompt => match key.code { + KeyCode::Enter => app.dismiss_violation_if_valid()?, + KeyCode::Backspace => { + let _ = app.violation_dismissal_reason.pop(); + } + KeyCode::Char(c) => { + app.violation_dismissal_reason.push(c); + } + _ => {} + }, State::ShouldQuit => (), State::End => { let code = match key.code { @@ -679,13 +757,13 @@ fn handle_events(app: &mut App) -> Result<()> { Ok(()) } -fn update_session_stats(app: &mut App) { +fn update_session_stats(app: &mut App) -> window::WindowSnapshot { let window_snapshot = window::get_snapshot(); - app.evidence_health = window_snapshot.health; + app.evidence_health = window_snapshot.health.clone(); let window_title = if app.state == State::Paused { constants::PAUSED.to_string() } else { - window_snapshot.title + window_snapshot.title.clone() }; let window_title = Rc::new(window_title); @@ -700,9 +778,16 @@ fn update_session_stats(app: &mut App) { app.current_window_title = window_title; app.session_ratings = session_stats_as_vec(&app.session_stats); } + + window_snapshot } fn ui(frame: &mut Frame, app: &App) { + if app.state == State::ViolationPrompt { + render_violation_prompt(frame, app); + return; + } + let layout = Layout::vertical([ Constraint::Min(3), Constraint::Min(3), @@ -890,6 +975,7 @@ fn ui(frame: &mut Frame, app: &App) { let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue)); spans.push(span); } + State::ViolationPrompt => {} State::ShouldQuit => {} State::End => { let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue)); @@ -904,10 +990,47 @@ fn ui(frame: &mut Frame, app: &App) { ); } +fn render_violation_prompt(frame: &mut Frame, app: &App) { + let layout = Layout::vertical([ + Constraint::Min(5), + Constraint::Length(3), + Constraint::Length(3), + ]); + let [layout_message, layout_reason, layout_status] = layout.areas(frame.size()); + + frame.render_widget( + Paragraph::new(Line::from(Span::raw(&app.violation_message))).block( + Block::bordered() + .border_type(BorderType::Thick) + .title(constants::VIOLATION_TITLE), + ), + layout_message, + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::raw(&app.violation_dismissal_reason))).block( + Block::bordered() + .border_type(BorderType::Thick) + .title(constants::VIOLATION_REASON_TITLE), + ), + layout_reason, + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + constants::VIOLATION_STATUS, + Style::new().fg(Color::LightRed), + ))) + .block(Block::bordered().title(constants::STATUS_TITLE)), + layout_status, + ); +} + #[cfg(test)] mod tests { use super::*; - use antidrift::domain::RuntimeState; + use antidrift::domain::{AllowedContext, EnforcementLevel, PolicySnapshot, RuntimeState}; + use antidrift::event_log::{EventLog, EventType}; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -948,6 +1071,91 @@ mod tests { let _ = fs::remove_file(path); } + #[test] + fn unknown_window_prompt_is_inactive_for_empty_allowlist() { + let policy = PolicySnapshot::for_runtime( + "commitment-test".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + let snapshot = window::WindowSnapshot { + title: "Any Window".to_string(), + class: Some("any-class".to_string()), + health: antidrift::domain::EvidenceHealth::Available, + }; + + assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); + } + + #[test] + fn unknown_window_prompt_is_inactive_for_matching_title() { + let policy = PolicySnapshot::for_runtime( + "commitment-test".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext { + window_title_substrings: vec!["Editor".to_string()], + ..AllowedContext::default() + }, + ); + let snapshot = window::WindowSnapshot { + title: "Code Editor - antidrift".to_string(), + class: None, + health: antidrift::domain::EvidenceHealth::Available, + }; + + assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); + } + + #[test] + fn unknown_window_prompt_is_active_for_nonmatching_title() { + let policy = PolicySnapshot::for_runtime( + "commitment-test".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext { + window_title_substrings: vec!["Editor".to_string()], + ..AllowedContext::default() + }, + ); + let snapshot = window::WindowSnapshot { + title: "Browser - unrelated".to_string(), + class: None, + health: antidrift::domain::EvidenceHealth::Available, + }; + + assert!(should_prompt_for_unknown_window(&policy, &snapshot)); + } + + #[test] + fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() { + let path = unique_event_log_path("violation-prompt-dismissal"); + 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(); + app.state = State::ViolationPrompt; + app.violation_message = "Unknown context detected: Browser - unrelated".to_string(); + + app.dismiss_violation_if_valid().unwrap(); + assert_eq!(app.state, State::ViolationPrompt); + + app.violation_dismissal_reason = "Needed to inspect docs".to_string(); + app.dismiss_violation_if_valid().unwrap(); + + assert_eq!(app.state, State::InProgress); + let records = EventLog::open(&path).unwrap().records().unwrap(); + assert!(records + .iter() + .any(|record| record.event_type == EventType::ViolationObserved + && record.payload_json["reason"] == "Needed to inspect docs")); + + let _ = fs::remove_file(path); + } + #[test] fn rating_completion_returns_controller_to_planning_for_next_session() { let path = unique_event_log_path("rating-completion"); diff --git a/src/session.rs b/src/session.rs index 244eab7..2003a05 100644 --- a/src/session.rs +++ b/src/session.rs @@ -8,6 +8,7 @@ use crate::state_machine::{ }; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::path::Path; use std::time::Duration; @@ -68,6 +69,14 @@ struct TransitionStartedPayload { commitment_to: CommitmentState, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ViolationObservedPayload { + schema_version: u16, + reason: String, + evidence: Value, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct PendingTransitionPolicy { commitment_id: String, @@ -523,6 +532,28 @@ impl SessionController { self.active_policy = Some(active_policy); Ok(()) } + + pub fn record_violation(&mut self, reason: impl Into, evidence: Value) -> Result<()> { + let payload = ViolationObservedPayload { + schema_version: POLICY_SCHEMA_VERSION, + reason: reason.into().trim().to_string(), + evidence, + }; + validate_violation_observed_payload(&payload)?; + + self.event_log + .append( + EventType::ViolationObserved, + self.runtime_state, + self.active_commitment + .as_ref() + .map(|commitment| commitment.id.clone()), + serde_json::to_value(payload).context("serialize violation observed payload")?, + ) + .context("log violation observation")?; + + Ok(()) + } } fn hydrate_session( @@ -832,6 +863,38 @@ fn hydrate_session( runtime_state = composite.runtime_state; active_commitment = Some(commitment); } + EventType::ViolationObserved => { + let payload: ViolationObservedPayload = + parse_payload(&record, "violation observed")?; + ensure_schema_version(payload.schema_version, record.sequence)?; + validate_violation_observed_payload(&payload).with_context(|| { + format!( + "validate violation observed payload at sequence {}", + record.sequence + ) + })?; + if record.runtime_state != runtime_state { + return Err(anyhow!( + "violation 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!( + "violation references unknown commitment '{}' at sequence {}", + commitment_id, + record.sequence + ) + })?; + if active.id != commitment_id { + return Err(anyhow!( + "violation commitment mismatch at sequence {}", + record.sequence + )); + } + } + } ref unsupported => { return Err(anyhow!( "unsupported session event {:?} at sequence {}", @@ -993,6 +1056,13 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re Ok(()) } +fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Result<()> { + if payload.reason.trim().is_empty() { + return Err(anyhow!("violation reason is required")); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ @@ -1176,6 +1246,71 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn records_violation_with_reason_evidence_and_active_commitment() { + let path = temp_log_path("violation-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_violation( + "Needed to inspect docs", + json!({ + "message": "Unknown context detected", + "window_title": "Browser - unrelated", + }), + ) + .unwrap(); + + let records = read_records(&path); + let violation = records + .iter() + .find(|record| record.event_type == EventType::ViolationObserved) + .unwrap(); + assert_eq!(violation.runtime_state, RuntimeState::Active); + assert_eq!( + violation.commitment_id.as_deref(), + Some(commitment_id.as_str()) + ); + assert_eq!(violation.payload_json["schema_version"], 1); + assert_eq!(violation.payload_json["reason"], "Needed to inspect docs"); + assert_eq!( + violation.payload_json["evidence"]["window_title"], + "Browser - unrelated" + ); + + 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 record_violation_rejects_empty_reason() { + let path = temp_log_path("violation-empty-reason"); + let mut session = SessionController::new(&path).unwrap(); + + let err = session + .record_violation(" ", json!({"message": "Unknown context detected"})) + .expect_err("empty violation reason must fail"); + + assert!(err.to_string().contains("reason")); + fs::remove_file(path).unwrap(); + } + #[test] fn activation_batch_emits_adjacent_stable_payloads() { let path = temp_log_path("activation-batch");