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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 /
|
||||
|
||||
Reference in New Issue
Block a user