feat(intentions): Phase 7 — if-then rules for task / time / triage triggers
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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:
2026-04-24 19:27:06 +01:00
parent eebea8cb9a
commit 6cd1c22c0f
12 changed files with 1180 additions and 10 deletions

View 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());
}
}

View File

@@ -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;

View File

@@ -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,