Add tier zero violation friction
This commit is contained in:
@@ -29,5 +29,8 @@ pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
|
|||||||
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
|
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
|
||||||
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
|
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
|
||||||
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
|
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_REASON: &str = "Provide transition reason! ";
|
||||||
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
|
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
|
||||||
|
|||||||
+214
-6
@@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; // <--- Add this
|
|||||||
mod constants;
|
mod constants;
|
||||||
|
|
||||||
use antidrift::{
|
use antidrift::{
|
||||||
domain::{EvidenceHealth, RuntimeState},
|
domain::{EvidenceHealth, PolicySnapshot, RuntimeState},
|
||||||
session::SessionController,
|
session::SessionController,
|
||||||
window,
|
window,
|
||||||
};
|
};
|
||||||
@@ -37,6 +37,7 @@ enum State {
|
|||||||
InputTransitionDuration,
|
InputTransitionDuration,
|
||||||
InProgress,
|
InProgress,
|
||||||
Paused,
|
Paused,
|
||||||
|
ViolationPrompt,
|
||||||
End,
|
End,
|
||||||
ShouldQuit,
|
ShouldQuit,
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,8 @@ struct App {
|
|||||||
transition_reason: String,
|
transition_reason: String,
|
||||||
transition_return_target: String,
|
transition_return_target: String,
|
||||||
transition_duration_str: String,
|
transition_duration_str: String,
|
||||||
|
violation_message: String,
|
||||||
|
violation_dismissal_reason: String,
|
||||||
current_window_title: Rc<String>,
|
current_window_title: Rc<String>,
|
||||||
evidence_health: EvidenceHealth,
|
evidence_health: EvidenceHealth,
|
||||||
session_controller: SessionController,
|
session_controller: SessionController,
|
||||||
@@ -188,6 +191,8 @@ impl App {
|
|||||||
transition_reason: String::new(),
|
transition_reason: String::new(),
|
||||||
transition_return_target: String::new(),
|
transition_return_target: String::new(),
|
||||||
transition_duration_str: "2".to_string(),
|
transition_duration_str: "2".to_string(),
|
||||||
|
violation_message: String::new(),
|
||||||
|
violation_dismissal_reason: String::new(),
|
||||||
current_window_title: window_snapshot.title.into(),
|
current_window_title: window_snapshot.title.into(),
|
||||||
evidence_health: window_snapshot.health,
|
evidence_health: window_snapshot.health,
|
||||||
session_controller,
|
session_controller,
|
||||||
@@ -288,6 +293,30 @@ impl App {
|
|||||||
Ok(())
|
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<()> {
|
fn to_end(&mut self) -> Result<()> {
|
||||||
match self.session_controller.runtime_state() {
|
match self.session_controller.runtime_state() {
|
||||||
RuntimeState::Transition => {
|
RuntimeState::Transition => {
|
||||||
@@ -354,6 +383,7 @@ impl App {
|
|||||||
constants::STATUS_PAUSED,
|
constants::STATUS_PAUSED,
|
||||||
duration_as_str(&self.session_remaining)
|
duration_as_str(&self.session_remaining)
|
||||||
),
|
),
|
||||||
|
State::ViolationPrompt => constants::VIOLATION_STATUS.to_string(),
|
||||||
State::End => constants::STATUS_RATE_SESSION.to_string(),
|
State::End => constants::STATUS_RATE_SESSION.to_string(),
|
||||||
State::ShouldQuit => constants::STATUS_QUIT.to_string(),
|
State::ShouldQuit => constants::STATUS_QUIT.to_string(),
|
||||||
}
|
}
|
||||||
@@ -384,6 +414,7 @@ impl App {
|
|||||||
window::minimize_other(&constants::APP_TITLE);
|
window::minimize_other(&constants::APP_TITLE);
|
||||||
update_session_stats(self);
|
update_session_stats(self);
|
||||||
}
|
}
|
||||||
|
State::ViolationPrompt => {}
|
||||||
State::End => {
|
State::End => {
|
||||||
window::minimize_other(&constants::APP_TITLE);
|
window::minimize_other(&constants::APP_TITLE);
|
||||||
}
|
}
|
||||||
@@ -396,7 +427,18 @@ impl App {
|
|||||||
fn tick_active_session(&mut self) -> Result<()> {
|
fn tick_active_session(&mut self) -> Result<()> {
|
||||||
let elapsed = self.session_start.elapsed();
|
let elapsed = self.session_start.elapsed();
|
||||||
self.session_remaining = self.user_duration.saturating_sub(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() {
|
if self.session_remaining.is_zero() {
|
||||||
self.to_end()?;
|
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<Rc<String>, Duration>) -> Vec<SessionRating> {
|
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
|
||||||
let mut stats: Vec<_> = session_stats
|
let mut stats: Vec<_> = session_stats
|
||||||
.iter()
|
.iter()
|
||||||
@@ -656,6 +724,16 @@ fn handle_events(app: &mut App) -> Result<()> {
|
|||||||
app.resume()?;
|
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::ShouldQuit => (),
|
||||||
State::End => {
|
State::End => {
|
||||||
let code = match key.code {
|
let code = match key.code {
|
||||||
@@ -679,13 +757,13 @@ fn handle_events(app: &mut App) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_session_stats(app: &mut App) {
|
fn update_session_stats(app: &mut App) -> window::WindowSnapshot {
|
||||||
let window_snapshot = window::get_snapshot();
|
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 {
|
let window_title = if app.state == State::Paused {
|
||||||
constants::PAUSED.to_string()
|
constants::PAUSED.to_string()
|
||||||
} else {
|
} else {
|
||||||
window_snapshot.title
|
window_snapshot.title.clone()
|
||||||
};
|
};
|
||||||
let window_title = Rc::new(window_title);
|
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.current_window_title = window_title;
|
||||||
app.session_ratings = session_stats_as_vec(&app.session_stats);
|
app.session_ratings = session_stats_as_vec(&app.session_stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window_snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ui(frame: &mut Frame, app: &App) {
|
fn ui(frame: &mut Frame, app: &App) {
|
||||||
|
if app.state == State::ViolationPrompt {
|
||||||
|
render_violation_prompt(frame, app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let layout = Layout::vertical([
|
let layout = Layout::vertical([
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
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));
|
let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue));
|
||||||
spans.push(span);
|
spans.push(span);
|
||||||
}
|
}
|
||||||
|
State::ViolationPrompt => {}
|
||||||
State::ShouldQuit => {}
|
State::ShouldQuit => {}
|
||||||
State::End => {
|
State::End => {
|
||||||
let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue));
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use antidrift::domain::RuntimeState;
|
use antidrift::domain::{AllowedContext, EnforcementLevel, PolicySnapshot, RuntimeState};
|
||||||
|
use antidrift::event_log::{EventLog, EventType};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -948,6 +1071,91 @@ mod tests {
|
|||||||
let _ = fs::remove_file(path);
|
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]
|
#[test]
|
||||||
fn rating_completion_returns_controller_to_planning_for_next_session() {
|
fn rating_completion_returns_controller_to_planning_for_next_session() {
|
||||||
let path = unique_event_log_path("rating-completion");
|
let path = unique_event_log_path("rating-completion");
|
||||||
|
|||||||
+135
@@ -8,6 +8,7 @@ use crate::state_machine::{
|
|||||||
};
|
};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -68,6 +69,14 @@ struct TransitionStartedPayload {
|
|||||||
commitment_to: CommitmentState,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct PendingTransitionPolicy {
|
struct PendingTransitionPolicy {
|
||||||
commitment_id: String,
|
commitment_id: String,
|
||||||
@@ -523,6 +532,28 @@ impl SessionController {
|
|||||||
self.active_policy = Some(active_policy);
|
self.active_policy = Some(active_policy);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_violation(&mut self, reason: impl Into<String>, 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(
|
fn hydrate_session(
|
||||||
@@ -832,6 +863,38 @@ fn hydrate_session(
|
|||||||
runtime_state = composite.runtime_state;
|
runtime_state = composite.runtime_state;
|
||||||
active_commitment = Some(commitment);
|
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 => {
|
ref unsupported => {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"unsupported session event {:?} at sequence {}",
|
"unsupported session event {:?} at sequence {}",
|
||||||
@@ -993,6 +1056,13 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
@@ -1176,6 +1246,71 @@ mod tests {
|
|||||||
fs::remove_file(path).unwrap();
|
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]
|
#[test]
|
||||||
fn activation_batch_emits_adjacent_stable_payloads() {
|
fn activation_batch_emits_adjacent_stable_payloads() {
|
||||||
let path = temp_log_path("activation-batch");
|
let path = temp_log_path("activation-batch");
|
||||||
|
|||||||
Reference in New Issue
Block a user