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

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