feat(intentions): Phase 7 — if-then rules for task / time / triage triggers
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.
This commit is contained in:
308
src-tauri/src/commands/intentions.rs
Normal file
308
src-tauri/src/commands/intentions.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod diagnostics;
|
||||
pub mod feedback;
|
||||
pub mod hardware;
|
||||
pub mod hotkey;
|
||||
pub mod intentions;
|
||||
pub mod live;
|
||||
pub mod llm;
|
||||
pub mod meeting;
|
||||
|
||||
@@ -329,6 +329,12 @@ pub fn run() {
|
||||
commands::rituals::mark_morning_triage_shown,
|
||||
// Nudges (Phase 6 roadmap)
|
||||
commands::nudges::deliver_nudge,
|
||||
// Implementation intentions (Phase 7 roadmap)
|
||||
commands::intentions::list_implementation_rules,
|
||||
commands::intentions::create_implementation_rule,
|
||||
commands::intentions::set_implementation_rule_enabled,
|
||||
commands::intentions::mark_implementation_rule_fired,
|
||||
commands::intentions::delete_implementation_rule,
|
||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||
commands::profiles::list_profiles_cmd,
|
||||
commands::profiles::get_profile_cmd,
|
||||
|
||||
Reference in New Issue
Block a user