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.

View File

@@ -8,13 +8,15 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
pub use database::{
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
insert_transcript, list_feedback_examples, list_profile_terms, list_profiles,
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
log_error, record_feedback, search_transcripts, set_setting, set_task_energy, uncomplete_task,
update_profile, update_task, update_transcript, update_transcript_meta, ErrorLogRow,
FeedbackRow, FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow,
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
get_transcript, init, insert_implementation_rule, insert_subtask, insert_task,
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts,
list_transcripts_paged, log_error, mark_implementation_rule_fired, record_feedback,
search_transcripts, set_implementation_rule_enabled, set_setting, set_task_energy,
uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta,
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams,
ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};

View File

@@ -400,6 +400,36 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ON tasks(energy, created_at DESC);
"#,
),
(
12,
"implementation intentions: if-then automation rules",
r#"
-- Phase 7 of the feature-complete roadmap. Rules are local-only,
-- user-authored implementation intentions: "if this happens, then
-- do this small thing". Execution stays in the frontend event bus;
-- SQLite owns the durable definition and the once-per-day marker
-- for time-of-day rules.
CREATE TABLE implementation_rules (
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
CHECK (enabled IN (0, 1)),
trigger_kind TEXT NOT NULL
CHECK (trigger_kind IN (
'time_of_day',
'task_completed',
'morning_triage_finished'
)),
trigger_value TEXT NOT NULL DEFAULT '',
actions_json TEXT NOT NULL DEFAULT '[]',
last_fired_key TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_implementation_rules_enabled_trigger
ON implementation_rules(enabled, trigger_kind);
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -549,7 +579,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 11);
assert_eq!(count, 12);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -568,7 +598,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 11);
assert_eq!(count, 12);
}
#[tokio::test]
@@ -596,6 +626,44 @@ mod tests {
}
}
#[tokio::test]
async fn migration_implementation_rules_adds_rule_table() {
let pool = fk_test_pool().await;
run_migrations(&pool).await.expect("migrate");
let info = sqlx::query("PRAGMA table_info(implementation_rules)")
.fetch_all(&pool)
.await
.unwrap();
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
for col in [
"id",
"enabled",
"trigger_kind",
"trigger_value",
"actions_json",
"last_fired_key",
"created_at",
"updated_at",
] {
assert!(
names.contains(&col.to_string()),
"implementation_rules must have {col}; got {names:?}"
);
}
let rejected = sqlx::query(
"INSERT INTO implementation_rules (id, trigger_kind, actions_json)
VALUES ('bad', 'calendar_event', '[]')",
)
.execute(&pool)
.await;
assert!(
rejected.is_err(),
"trigger_kind CHECK constraint must reject unknown triggers"
);
}
#[tokio::test]
async fn migration_transcripts_meta_adds_columns() {
// Task 2.5 — verify starred / manual_tags / template / language /