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:
@@ -524,6 +524,125 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Implementation intentions ---
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn insert_implementation_rule(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
trigger_kind: &str,
|
||||
trigger_value: &str,
|
||||
actions_json: &str,
|
||||
last_fired_key: Option<&str>,
|
||||
) -> Result<ImplementationRuleRow> {
|
||||
sqlx::query(
|
||||
"INSERT INTO implementation_rules (
|
||||
id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key
|
||||
) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(enabled as i64)
|
||||
.bind(trigger_kind)
|
||||
.bind(trigger_value)
|
||||
.bind(actions_json)
|
||||
.bind(last_fired_key)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert implementation rule failed: {e}")))?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!(
|
||||
"insert_implementation_rule: rule {id} not found after insert"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
||||
created_at, updated_at \
|
||||
FROM implementation_rules ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List implementation rules failed: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(implementation_rule_row_from).collect())
|
||||
}
|
||||
|
||||
pub async fn get_implementation_rule(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
) -> Result<Option<ImplementationRuleRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
||||
created_at, updated_at \
|
||||
FROM implementation_rules WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get implementation rule failed: {e}")))?;
|
||||
|
||||
Ok(row.map(implementation_rule_row_from))
|
||||
}
|
||||
|
||||
pub async fn set_implementation_rule_enabled(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<ImplementationRuleRow> {
|
||||
sqlx::query(
|
||||
"UPDATE implementation_rules
|
||||
SET enabled = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(enabled as i64)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Set implementation rule enabled failed: {e}")))?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!(
|
||||
"set_implementation_rule_enabled: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_implementation_rule_fired(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
last_fired_key: &str,
|
||||
) -> Result<ImplementationRuleRow> {
|
||||
sqlx::query(
|
||||
"UPDATE implementation_rules
|
||||
SET last_fired_key = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(last_fired_key)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Mark implementation rule fired failed: {e}")))?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!(
|
||||
"mark_implementation_rule_fired: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM implementation_rules WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Delete implementation rule failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Settings CRUD ---
|
||||
|
||||
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
||||
@@ -612,6 +731,18 @@ pub struct TaskRow {
|
||||
pub energy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImplementationRuleRow {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
pub trigger_kind: String,
|
||||
pub trigger_value: String,
|
||||
pub actions_json: String,
|
||||
pub last_fired_key: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
TranscriptRow {
|
||||
id: r.get("id"),
|
||||
@@ -676,6 +807,19 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
||||
ImplementationRuleRow {
|
||||
id: r.get("id"),
|
||||
enabled: r.get::<i64, _>("enabled") != 0,
|
||||
trigger_kind: r.get("trigger_kind"),
|
||||
trigger_value: r.get("trigger_value"),
|
||||
actions_json: r.get("actions_json"),
|
||||
last_fired_key: r.get("last_fired_key"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Profile CRUD (Phase 2 Task 11) ---
|
||||
//
|
||||
// Profiles partition the per-user transcription dictionary so that a single
|
||||
@@ -1318,6 +1462,46 @@ mod tests {
|
||||
assert!(b.done, "sibling must be untouched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_rule_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
let rule = insert_implementation_rule(
|
||||
&pool,
|
||||
"rule-1",
|
||||
true,
|
||||
"time_of_day",
|
||||
"09:00",
|
||||
r#"[{"kind":"speak_line","text":"time to plan the day"}]"#,
|
||||
Some("2026-04-24"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rule.id, "rule-1");
|
||||
assert!(rule.enabled);
|
||||
assert_eq!(rule.trigger_kind, "time_of_day");
|
||||
assert_eq!(rule.trigger_value, "09:00");
|
||||
assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24"));
|
||||
|
||||
let rules = list_implementation_rules(&pool).await.unwrap();
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].actions_json, rule.actions_json);
|
||||
|
||||
let disabled = set_implementation_rule_enabled(&pool, "rule-1", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!disabled.enabled);
|
||||
|
||||
let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25"));
|
||||
|
||||
delete_implementation_rule(&pool, "rule-1").await.unwrap();
|
||||
assert!(list_implementation_rules(&pool).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_transcript_meta_happy_path() {
|
||||
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
||||
|
||||
Reference in New Issue
Block a user