Small if-then automation layer. Rules persist in SQLite; the runner lives on the frontend and binds to the Phase 6 event bus so the rule pipeline reuses the same delivery primitives (timer events, TTS, Tasks navigation). Storage: - Migration v12 adds implementation_rules (id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, created_at, updated_at) with enabled+trigger_kind index for the runner's hot path. - CRUD helpers: insert / list / get / set-enabled / mark-fired / delete, plus a round-trip test. Commands (all main-window-guarded via ensure_main_window): - list_implementation_rules - create_implementation_rule — validates HH:MM, checks the target task exists at save time for surface-task actions, caps speak-line at 240 chars, pins v1 timers to 5 minutes. - set_implementation_rule_enabled - mark_implementation_rule_fired — main-thread idempotency shim so the runner can atomically claim a fire. - delete_implementation_rule Runner (implementationIntentions.svelte.ts): - Subscribes to kon:task-completed and kon:morning-triage-finished (MorningTriageModal now emits on all three exit paths — empty, skipped, picked — so skip counts as finishing). - 30 s poll for time-of-day rules, plus an immediate check on startup so a rule whose time has already passed today catches up once. - Idempotency via last_fired_key composed as YYYY-MM-DD@HH:MM for time rules; new time rules whose HH:MM has already passed today are pre-seeded so they don't fire retroactively on save. - Rules are paused when Nudges "Mute for now" is on — a hard mute stops all rule delivery in addition to OS notifications. - Stale-task safety: if a surface-task action's target has been deleted, the runner opens Tasks and warns clearly rather than pretending to surface something that's gone. Editor (ImplementationRulesEditor.svelte): - Lives in Settings under a new "If-then rules" accordion section. - `If` picker: time of day (with time input), a task completes, morning triage finishes. - `Then` composer: optional surface (inbox / today / all tasks / specific task), optional 5-min timer, optional speak-aloud line. - Saved rules list with enable toggle + delete. Rules table integration for Phase 10b rename sweep: add implementation_rules to the kon.db → corbie.db migration shim when that phase lands. Gates: fmt, clippy -D warnings, cargo test 265/0, svelte-check 0/0, npm build green. Pre-existing Vite chunk warning on sounds.ts is unrelated to Phase 7.
309 lines
9.3 KiB
Rust
309 lines
9.3 KiB
Rust
//! Phase 7 implementation intentions: small if-then automation rules.
|
|
//!
|
|
//! The frontend owns scheduling and execution because the v1 triggers are
|
|
//! app-local events (timer/task/ritual) plus local clock checks. Rust owns
|
|
//! durable rule storage, validation, and main-window-only command access.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use kon_storage::{
|
|
delete_implementation_rule as db_delete_rule, get_task_by_id,
|
|
insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules,
|
|
mark_implementation_rule_fired as db_mark_rule_fired,
|
|
set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow,
|
|
};
|
|
|
|
use crate::commands::security::ensure_main_window;
|
|
use crate::AppState;
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RuleTriggerKind {
|
|
TimeOfDay,
|
|
TaskCompleted,
|
|
MorningTriageFinished,
|
|
}
|
|
|
|
impl RuleTriggerKind {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
RuleTriggerKind::TimeOfDay => "time_of_day",
|
|
RuleTriggerKind::TaskCompleted => "task_completed",
|
|
RuleTriggerKind::MorningTriageFinished => "morning_triage_finished",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SurfaceTarget {
|
|
Inbox,
|
|
Today,
|
|
Tasks,
|
|
Task,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum RuleAction {
|
|
Surface {
|
|
target: SurfaceTarget,
|
|
#[serde(default)]
|
|
#[serde(rename = "taskId")]
|
|
task_id: Option<String>,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
},
|
|
StartTimer {
|
|
seconds: u32,
|
|
#[serde(default)]
|
|
#[serde(rename = "taskId")]
|
|
task_id: Option<String>,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
},
|
|
SpeakLine {
|
|
text: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateImplementationRuleRequest {
|
|
pub trigger_kind: RuleTriggerKind,
|
|
pub trigger_value: String,
|
|
pub actions: Vec<RuleAction>,
|
|
#[serde(default = "default_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub last_fired_key: Option<String>,
|
|
}
|
|
|
|
fn default_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ImplementationRuleDto {
|
|
pub id: String,
|
|
pub enabled: bool,
|
|
pub trigger_kind: RuleTriggerKind,
|
|
pub trigger_value: String,
|
|
pub actions: Vec<RuleAction>,
|
|
pub last_fired_key: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
impl TryFrom<ImplementationRuleRow> for ImplementationRuleDto {
|
|
type Error = String;
|
|
|
|
fn try_from(row: ImplementationRuleRow) -> Result<Self, Self::Error> {
|
|
let trigger_kind = match row.trigger_kind.as_str() {
|
|
"time_of_day" => RuleTriggerKind::TimeOfDay,
|
|
"task_completed" => RuleTriggerKind::TaskCompleted,
|
|
"morning_triage_finished" => RuleTriggerKind::MorningTriageFinished,
|
|
other => return Err(format!("unknown rule trigger kind: {other}")),
|
|
};
|
|
let actions = serde_json::from_str::<Vec<RuleAction>>(&row.actions_json)
|
|
.map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?;
|
|
Ok(Self {
|
|
id: row.id,
|
|
enabled: row.enabled,
|
|
trigger_kind,
|
|
trigger_value: row.trigger_value,
|
|
actions,
|
|
last_fired_key: row.last_fired_key,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn validate_hhmm(value: &str) -> Result<(), String> {
|
|
let (h, m) = value
|
|
.split_once(':')
|
|
.ok_or_else(|| "time rules need HH:MM".to_string())?;
|
|
if h.len() != 2 || m.len() != 2 {
|
|
return Err("time rules need HH:MM".into());
|
|
}
|
|
let hour: u8 = h
|
|
.parse()
|
|
.map_err(|_| "time hour must be 00-23".to_string())?;
|
|
let minute: u8 = m
|
|
.parse()
|
|
.map_err(|_| "time minute must be 00-59".to_string())?;
|
|
if hour > 23 || minute > 59 {
|
|
return Err("time must be between 00:00 and 23:59".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_actions(
|
|
state: &tauri::State<'_, AppState>,
|
|
actions: &[RuleAction],
|
|
) -> Result<(), String> {
|
|
if actions.is_empty() {
|
|
return Err("add at least one rule action".into());
|
|
}
|
|
for action in actions {
|
|
match action {
|
|
RuleAction::Surface {
|
|
target,
|
|
task_id,
|
|
label: _,
|
|
} => {
|
|
if matches!(target, SurfaceTarget::Task) {
|
|
let task_id = task_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|id| !id.is_empty())
|
|
.ok_or_else(|| "surface-task actions need a task".to_string())?;
|
|
let exists = get_task_by_id(&state.db, task_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.is_some();
|
|
if !exists {
|
|
return Err(format!("task {task_id} does not exist"));
|
|
}
|
|
}
|
|
}
|
|
RuleAction::StartTimer { seconds, .. } => {
|
|
if *seconds != 300 {
|
|
return Err("v1 rule timers are fixed at 5 minutes".into());
|
|
}
|
|
}
|
|
RuleAction::SpeakLine { text } => {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("speak actions need a line to read".into());
|
|
}
|
|
if trimmed.chars().count() > 240 {
|
|
return Err("speak lines must be 240 characters or fewer".into());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_request(
|
|
state: &tauri::State<'_, AppState>,
|
|
request: &CreateImplementationRuleRequest,
|
|
) -> Result<(), String> {
|
|
match request.trigger_kind {
|
|
RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?,
|
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => {
|
|
if !request.trigger_value.trim().is_empty() {
|
|
return Err("this trigger does not take an extra value".into());
|
|
}
|
|
}
|
|
}
|
|
validate_actions(state, &request.actions).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_implementation_rules(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
) -> Result<Vec<ImplementationRuleDto>, String> {
|
|
ensure_main_window(&window)?;
|
|
db_list_rules(&state.db)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.into_iter()
|
|
.map(ImplementationRuleDto::try_from)
|
|
.collect()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_implementation_rule(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
request: CreateImplementationRuleRequest,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
validate_request(&state, &request).await?;
|
|
|
|
let id = Uuid::new_v4().to_string();
|
|
let actions_json = serde_json::to_string(&request.actions)
|
|
.map_err(|e| format!("serialise rule actions failed: {e}"))?;
|
|
let trigger_value = match request.trigger_kind {
|
|
RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(),
|
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(),
|
|
};
|
|
|
|
db_insert_rule(
|
|
&state.db,
|
|
&id,
|
|
request.enabled,
|
|
request.trigger_kind.as_str(),
|
|
&trigger_value,
|
|
&actions_json,
|
|
request.last_fired_key.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_implementation_rule_enabled(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
enabled: bool,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
db_set_rule_enabled(&state.db, &id, enabled)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mark_implementation_rule_fired(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
fired_key: String,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
let key = fired_key.trim();
|
|
if key.is_empty() {
|
|
return Err("fired_key is required".into());
|
|
}
|
|
db_mark_rule_fired(&state.db, &id, key)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_implementation_rule(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
ensure_main_window(&window)?;
|
|
db_delete_rule(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::validate_hhmm;
|
|
|
|
#[test]
|
|
fn hhmm_validation_accepts_and_rejects_expected_shapes() {
|
|
assert!(validate_hhmm("09:00").is_ok());
|
|
assert!(validate_hhmm("23:59").is_ok());
|
|
assert!(validate_hhmm("9:00").is_err());
|
|
assert!(validate_hhmm("24:00").is_err());
|
|
assert!(validate_hhmm("08:60").is_err());
|
|
}
|
|
}
|