From ae51850ca389fc5ca4c5340bc39d799b940491c8 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 14:00:35 -0500 Subject: [PATCH 01/36] feat(automation): persist per-rule terminal exclusions --- src-tauri/src/automation/targeting.rs | 3 + src-tauri/src/automation_engine.rs | 3 + src-tauri/src/automation_engine/test_host.rs | 3 + src-tauri/src/automation_store.rs | 155 +++++++++++++++++-- src-tauri/src/automation_validation.rs | 6 + 5 files changed, 158 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/automation/targeting.rs b/src-tauri/src/automation/targeting.rs index 0774817..c9bf4b7 100644 --- a/src-tauri/src/automation/targeting.rs +++ b/src-tauri/src/automation/targeting.rs @@ -466,6 +466,9 @@ mod tests { criterion_value: value.into(), follow_new, target_ids: vec!["tm-pinned".into()], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 0, diff --git a/src-tauri/src/automation_engine.rs b/src-tauri/src/automation_engine.rs index 521cbe4..4e68a53 100644 --- a/src-tauri/src/automation_engine.rs +++ b/src-tauri/src/automation_engine.rs @@ -840,6 +840,9 @@ mod tests { criterion_value: String::new(), follow_new: true, target_ids: vec!["tm-1".to_string()], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 1, diff --git a/src-tauri/src/automation_engine/test_host.rs b/src-tauri/src/automation_engine/test_host.rs index 55e164a..2c13ce8 100644 --- a/src-tauri/src/automation_engine/test_host.rs +++ b/src-tauri/src/automation_engine/test_host.rs @@ -205,6 +205,9 @@ pub(crate) fn ctx_rule(id: &str) -> AutomationRule { criterion_value: String::new(), follow_new: true, target_ids: vec![], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 1, diff --git a/src-tauri/src/automation_store.rs b/src-tauri/src/automation_store.rs index a963ca2..1221b33 100644 --- a/src-tauri/src/automation_store.rs +++ b/src-tauri/src/automation_store.rs @@ -592,6 +592,12 @@ pub struct AutomationRule { /// a rule saved across a restart would point at nothing. Plan §7.4. #[serde(default)] pub target_ids: Vec, + #[serde(default)] + pub excluded_ids: Vec, + #[serde(default)] + pub exclude_criterion: Option, + #[serde(default)] + pub exclude_criterion_value: String, // --- runtime flags that outlive a process --- /// Set when a `runs_once` rule fires. `None` means it can still run. @@ -744,8 +750,12 @@ const LAST_SEEN_THROTTLE_MS: i64 = 5 * 60 * 1000; const PINNED_TARGET_IDS_SQL: &str = "SELECT terminal_id FROM automation_targets \ WHERE rule_id = ?1 AND source = 'pinned' ORDER BY added_at, terminal_id"; +const EXCLUDED_TARGET_IDS_SQL: &str = "SELECT terminal_id FROM automation_exclusions \ + WHERE rule_id = ?1 ORDER BY added_at, terminal_id"; + const RULE_COLUMNS: &str ="id, name, enabled, runs_once, target_mode, criterion, criterion_value, \ - follow_new, completed_at, verbose_until, sort_order, schema_version, graph, created_at, updated_at"; + exclude_criterion, exclude_criterion_value, follow_new, completed_at, verbose_until, sort_order, \ + schema_version, graph, created_at, updated_at"; /// Whether an entry is subject to the verbose gate. **Derived from `kind` inside `append`, never /// passed in.** A caller that could label its own entry could gate a `Sent` behind the verbose flag @@ -812,6 +822,8 @@ struct RawRule { target_mode: String, criterion: String, criterion_value: String, + exclude_criterion: Option, + exclude_criterion_value: Option, follow_new: bool, completed_at: Option, verbose_until: Option, @@ -831,14 +843,16 @@ fn read_rule_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { target_mode: r.get(4)?, criterion: r.get(5)?, criterion_value: r.get(6)?, - follow_new: r.get(7)?, - completed_at: r.get(8)?, - verbose_until: r.get(9)?, - sort_order: r.get(10)?, - schema_version: r.get(11)?, - graph: r.get(12)?, - created_at: r.get(13)?, - updated_at: r.get(14)?, + exclude_criterion: r.get(7)?, + exclude_criterion_value: r.get(8)?, + follow_new: r.get(9)?, + completed_at: r.get(10)?, + verbose_until: r.get(11)?, + sort_order: r.get(12)?, + schema_version: r.get(13)?, + graph: r.get(14)?, + created_at: r.get(15)?, + updated_at: r.get(16)?, }) } @@ -849,6 +863,7 @@ fn hydrate_rule(raw: RawRule) -> Result { })?, target_mode: enum_from_db(&raw.target_mode)?, criterion: enum_from_db(&raw.criterion)?, + exclude_criterion: raw.exclude_criterion.as_deref().map(enum_from_db).transpose()?, id: raw.id, name: raw.name, enabled: raw.enabled, @@ -856,6 +871,8 @@ fn hydrate_rule(raw: RawRule) -> Result { criterion_value: raw.criterion_value, follow_new: raw.follow_new, target_ids: Vec::new(), + excluded_ids: Vec::new(), + exclude_criterion_value: raw.exclude_criterion_value.unwrap_or_default(), completed_at: raw.completed_at, verbose_until: raw.verbose_until, sort_order: raw.sort_order, @@ -996,6 +1013,8 @@ impl AutomationStore { target_mode TEXT NOT NULL, criterion TEXT NOT NULL, criterion_value TEXT NOT NULL, + exclude_criterion TEXT, + exclude_criterion_value TEXT, follow_new INTEGER NOT NULL, completed_at INTEGER, verbose_until INTEGER, @@ -1025,6 +1044,15 @@ impl AutomationStore { )", [], )?; + conn.execute( + "CREATE TABLE IF NOT EXISTS automation_exclusions ( + rule_id TEXT NOT NULL, + terminal_id TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (rule_id, terminal_id) + )", + [], + )?; // Ordered by `id`, never by `at`: two entries can share a millisecond (verbose mode writes // several terminals per tick) and the wall clock can move backwards after an NTP correction or // a resume, which this app already handles as an event. AUTOINCREMENT rather than a bare @@ -1052,6 +1080,8 @@ impl AutomationStore { // build of this branch keeps its old `automation_targets` and every SELECT naming `folder` // fails against it. Plan §3.4. Self::ensure_column(conn, "automation_targets", "folder", "TEXT")?; + Self::ensure_column(conn, "automation_rules", "exclude_criterion", "TEXT")?; + Self::ensure_column(conn, "automation_rules", "exclude_criterion_value", "TEXT")?; Ok(()) } @@ -1113,6 +1143,19 @@ impl AutomationStore { } } + let mut exclusions: HashMap> = HashMap::new(); + { + let mut stmt = conn.prepare( + "SELECT rule_id, terminal_id FROM automation_exclusions ORDER BY added_at, terminal_id", + )?; + let rows = + stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + for row in rows { + let (rule_id, terminal_id) = row?; + exclusions.entry(rule_id).or_default().push(terminal_id); + } + } + let mut stmt = conn.prepare(&format!( "SELECT {RULE_COLUMNS} FROM automation_rules ORDER BY sort_order, id" ))?; @@ -1124,6 +1167,7 @@ impl AutomationStore { match hydrate_rule(raw) { Ok(mut rule) => { rule.target_ids = targets.remove(&rule.id).unwrap_or_default(); + rule.excluded_ids = exclusions.remove(&rule.id).unwrap_or_default(); out.push(rule); } // §3.3: a row this build cannot decode is ONE rule that does not run, never @@ -1497,6 +1541,13 @@ impl AutomationStore { ids.push(row?); } rule.target_ids = ids; + let mut stmt = conn.prepare(EXCLUDED_TARGET_IDS_SQL)?; + let rows = stmt.query_map([id], |r| r.get::<_, String>(0))?; + let mut ids = Vec::new(); + for row in rows { + ids.push(row?); + } + rule.excluded_ids = ids; Ok(Some(rule)) } } @@ -1600,6 +1651,7 @@ impl AutomationStore { .map_err(|e| AutomationStoreError::Invalid(format!("graph is not serialisable: {e}")))?; let target_mode = enum_to_db(&rule.target_mode)?; let criterion = enum_to_db(&rule.criterion)?; + let exclude_criterion = rule.exclude_criterion.as_ref().map(enum_to_db).transpose()?; let schema_version = if rule.schema_version <= SUPPORTED_SCHEMA_VERSION { schema_version_for(&rule.graph) } else { @@ -1614,9 +1666,10 @@ impl AutomationStore { tx.execute( "INSERT INTO automation_rules ( - id, name, enabled, runs_once, target_mode, criterion, criterion_value, follow_new, - completed_at, verbose_until, sort_order, schema_version, graph, created_at, updated_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15) + id, name, enabled, runs_once, target_mode, criterion, criterion_value, + exclude_criterion, exclude_criterion_value, follow_new, completed_at, verbose_until, + sort_order, schema_version, graph, created_at, updated_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17) ON CONFLICT(id) DO UPDATE SET name = excluded.name, enabled = excluded.enabled, @@ -1624,6 +1677,8 @@ impl AutomationStore { target_mode = excluded.target_mode, criterion = excluded.criterion, criterion_value = excluded.criterion_value, + exclude_criterion = excluded.exclude_criterion, + exclude_criterion_value = excluded.exclude_criterion_value, follow_new = excluded.follow_new, completed_at = excluded.completed_at, verbose_until = excluded.verbose_until, @@ -1639,6 +1694,8 @@ impl AutomationStore { target_mode, criterion, rule.criterion_value, + exclude_criterion, + rule.exclude_criterion_value, rule.follow_new, rule.completed_at, rule.verbose_until, @@ -1693,6 +1750,17 @@ impl AutomationStore { rusqlite::params![rule.id, id], )?; } + tx.execute( + "DELETE FROM automation_exclusions WHERE rule_id = ?1", + [&rule.id], + )?; + for id in &rule.excluded_ids { + tx.execute( + "INSERT OR IGNORE INTO automation_exclusions (rule_id, terminal_id, added_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![rule.id, id, rule.updated_at], + )?; + } Ok(previous) } @@ -1703,6 +1771,7 @@ impl AutomationStore { let conn = guard.as_mut().ok_or(AutomationStoreError::Disabled)?; let tx = conn.transaction()?; tx.execute("DELETE FROM automation_targets WHERE rule_id = ?1", [id])?; + tx.execute("DELETE FROM automation_exclusions WHERE rule_id = ?1", [id])?; tx.execute("DELETE FROM automation_log WHERE rule_id = ?1", [id])?; let n = tx.execute("DELETE FROM automation_rules WHERE id = ?1", [id])?; tx.commit()?; @@ -2330,6 +2399,9 @@ mod tests { // A PINNED rule with no targets is one the enable gate refuses, so a fixture that had // none was every store test arranging a row the product cannot produce. target_ids: vec!["tm-1".to_string()], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 1, @@ -2340,6 +2412,65 @@ mod tests { } } + #[test] + fn exclusions_round_trip_through_save_and_list() { + let store = AutomationStore::new_in_memory(); + let mut r = rule("au-x"); + r.target_mode = TargetMode::Rule; + r.criterion = Criterion::CommandContains; + r.criterion_value = "claude".into(); + r.excluded_ids = vec!["tm-b".into(), "tm-c".into()]; + r.exclude_criterion = Some(Criterion::WorkingFolderUnder); + r.exclude_criterion_value = "~/scratch".into(); + store.save_rule(&r).unwrap(); + + let back = store.list_rules().unwrap().into_iter().find(|x| x.id == "au-x").unwrap(); + assert_eq!(back.excluded_ids, vec!["tm-b".to_string(), "tm-c".to_string()]); + assert_eq!(back.exclude_criterion, Some(Criterion::WorkingFolderUnder)); + assert_eq!(back.exclude_criterion_value, "~/scratch"); + } + + /// A terminal can be BOTH a pick and an exclusion, and one must not erase the other. This is the + /// test that fails if exclusions are squeezed into `automation_targets.source`, whose primary key + /// (`:1024`) has no room for two memberships of one pair. + #[test] + fn a_terminal_can_be_both_pinned_and_excluded_without_either_erasing_the_other() { + let store = AutomationStore::new_in_memory(); + let mut r = rule("au-x"); + r.target_ids = vec!["tm-a".into(), "tm-b".into()]; // picks + r.excluded_ids = vec!["tm-b".into()]; // and tm-b is also excluded + store.save_rule(&r).unwrap(); + + let back = store.list_rules().unwrap().into_iter().find(|x| x.id == "au-x").unwrap(); + assert_eq!(back.target_ids, vec!["tm-a".to_string(), "tm-b".to_string()]); + assert_eq!(back.excluded_ids, vec!["tm-b".to_string()]); + } + + /// BOTH loaders, not just the bulk one. `read_rule_on` is the path get_rule, duplicate, enable and + /// every target mutation take; if only `list_rules` learns exclusions, a duplicate silently drops + /// them and a target edit writes the rule back without them. + #[test] + fn the_single_rule_loader_returns_exclusions_too() { + let store = AutomationStore::new_in_memory(); + let mut r = rule("au-x"); + r.excluded_ids = vec!["tm-b".into()]; + store.save_rule(&r).unwrap(); + + let one = store.get_rule("au-x").unwrap().unwrap(); + assert_eq!(one.excluded_ids, vec!["tm-b".to_string()], "get_rule must agree with list_rules"); + } + + /// Exclusions must survive a mutation that rewrites the rule for an unrelated reason. + #[test] + fn a_target_mutation_preserves_exclusions() { + let store = AutomationStore::new_in_memory(); + let mut r = rule("au-x"); + r.excluded_ids = vec!["tm-b".into()]; + store.save_rule(&r).unwrap(); + store.add_target_to_rule("au-x", "tm-z", 2_000).unwrap(); + assert_eq!(store.get_rule("au-x").unwrap().unwrap().excluded_ids, vec!["tm-b".to_string()]); + } + /// Insert a row whose `graph` column is arbitrary text, bypassing `save_rule`'s /// serialisation. There is no other way to author a row this build cannot decode. fn write_raw_graph(store: &AutomationStore, id: &str, graph: &str) { diff --git a/src-tauri/src/automation_validation.rs b/src-tauri/src/automation_validation.rs index 1bfa2e1..9cd334b 100644 --- a/src-tauri/src/automation_validation.rs +++ b/src-tauri/src/automation_validation.rs @@ -869,6 +869,9 @@ mod tests { criterion_value: String::new(), follow_new: true, target_ids: vec!["tm-1".into()], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 1, @@ -1440,6 +1443,9 @@ mod tests { criterion_value: String::new(), follow_new: true, target_ids: vec![], + excluded_ids: vec![], + exclude_criterion: None, + exclude_criterion_value: String::new(), completed_at: None, verbose_until: None, sort_order: 0, From 0cb78554d4f4e29457ee36c80d3f1999b59df96d Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 14:10:00 -0500 Subject: [PATCH 02/36] feat(automation): subtract excluded terminals in the targeting gate --- src-tauri/src/automation/targeting.rs | 51 +++++++++++++++++++++- src-tauri/src/automation_engine.rs | 61 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/automation/targeting.rs b/src-tauri/src/automation/targeting.rs index c9bf4b7..6199fef 100644 --- a/src-tauri/src/automation/targeting.rs +++ b/src-tauri/src/automation/targeting.rs @@ -165,13 +165,21 @@ pub fn watched_set( rows: &[RosterRow], previous: Option<&BTreeSet>, ) -> BTreeSet { - match rule.target_mode { + let mut watched = match rule.target_mode { TargetMode::Pinned => rule.target_ids.iter().cloned().collect(), TargetMode::Rule => match (rule.follow_new, previous) { (false, Some(frozen)) if !frozen.is_empty() => frozen.clone(), _ => resolve(rule.criterion, &rule.criterion_value, rows), }, + }; + if rule.target_mode == TargetMode::Rule { + let mut excluded: BTreeSet = rule.excluded_ids.iter().cloned().collect(); + if let Some(criterion) = rule.exclude_criterion { + excluded.extend(resolve(criterion, &rule.exclude_criterion_value, rows)); + } + watched.retain(|terminal_id| !excluded.contains(terminal_id)); } + watched } #[cfg(test)] @@ -568,6 +576,47 @@ mod tests { assert_eq!(ids(watched_set(&pinned, &[], None)), vec!["tm-pinned"]); } + #[test] + fn a_pinned_rule_ignores_exclusions_entirely() { + let rows = vec![row(Some("tm-a"), None, None, Some("claude"))]; + let mut r = rule(TargetMode::Pinned, Criterion::CommandContains, "claude", true); + r.target_ids = vec!["tm-a".into()]; + r.excluded_ids = vec!["tm-a".into()]; + assert_eq!( + ids(watched_set(&r, &rows, None)), + vec!["tm-a"], + "a hand-picked set says what it means; exclusions do not apply" + ); + } + + #[test] + fn an_exclusion_pattern_removes_everything_it_matches() { + let rows = vec![ + row(Some("tm-scratch"), None, Some("~/scratch/project"), None), + row(Some("tm-work"), None, Some("~/work/termflow"), None), + ]; + let mut r = rule(TargetMode::Rule, Criterion::AllTerminals, "", true); + r.exclude_criterion = Some(Criterion::WorkingFolderUnder); + r.exclude_criterion_value = "~/scratch".into(); + + assert_eq!(ids(watched_set(&r, &rows, None)), vec!["tm-work"]); + } + + #[test] + fn the_two_exclusion_kinds_union_rather_than_override() { + let rows = vec![ + row(Some("tm-id"), None, Some("~/work/termflow"), None), + row(Some("tm-pattern"), None, Some("~/scratch/project"), None), + row(Some("tm-kept"), None, Some("~/work/other"), None), + ]; + let mut r = rule(TargetMode::Rule, Criterion::AllTerminals, "", true); + r.excluded_ids = vec!["tm-id".into()]; + r.exclude_criterion = Some(Criterion::WorkingFolderUnder); + r.exclude_criterion_value = "~/scratch".into(); + + assert_eq!(ids(watched_set(&r, &rows, None)), vec!["tm-kept"]); + } + // ----------------------------------------------------------------------------------------- // §10.12b — the departure is reported once // ----------------------------------------------------------------------------------------- diff --git a/src-tauri/src/automation_engine.rs b/src-tauri/src/automation_engine.rs index 4e68a53..bc04c63 100644 --- a/src-tauri/src/automation_engine.rs +++ b/src-tauri/src/automation_engine.rs @@ -1970,6 +1970,67 @@ mod tests { assert_eq!(engine.runtime.arm_state("au-a", "tm-1"), ArmState::Fired { at_ms: 500 }); } + /// "Un-ticking puts it back." An exclusion is a filter over the matched set, never a deletion from + /// it — and this must hold on a FROZEN (`follow_new: false`) rule, which is the case that can bake + /// the exclusion in. Spec §B3. Distinct timestamps are load-bearing: reload compares `updated_at`, + /// not content, so a same-millisecond save would not clear the set and the test would pass + /// vacuously. + #[test] + fn lifting_an_exclusion_restores_the_terminal_on_a_frozen_rule() { + let fake = Arc::new( + crate::automation_engine::test_host::FakeHost::new() + .with_terminal("tm-a", "pc-a", "a") + .with_terminal("tm-b", "pc-b", "b"), + ); + let host: Arc = fake.clone(); + let engine = Arc::new(AutomationEngine::new(0)); + + let mut r = rule("au-frozen", r"ctx:(\d+)%"); + r.target_mode = TargetMode::Rule; + r.criterion = Criterion::AllTerminals; + r.criterion_value.clear(); + r.follow_new = false; + r.updated_at = 1_000; + fake.store.save_rule(&r).unwrap(); + engine.reload(&fake.store, 1_000).unwrap(); + crate::automation_engine::loops::targeting_tick(&engine, &host, 1_000); + assert_eq!( + engine.runtime.watched_for("au-frozen"), + HashSet::from(["tm-a".to_string(), "tm-b".to_string()]), + "premise: the frozen base set contains both terminals" + ); + + r.excluded_ids = vec!["tm-b".into()]; + r.updated_at = 2_000; + fake.store.save_rule(&r).unwrap(); + engine.reload(&fake.store, 2_000).unwrap(); + assert!( + engine.runtime.watched_for("au-frozen").is_empty(), + "the changed timestamp must clear the frozen set before the next targeting pass" + ); + crate::automation_engine::loops::targeting_tick(&engine, &host, 2_000); + assert_eq!( + engine.runtime.watched_for("au-frozen"), + HashSet::from(["tm-a".to_string()]), + "the exclusion filters tm-b from the frozen base set" + ); + + r.excluded_ids.clear(); + r.updated_at = 3_000; + fake.store.save_rule(&r).unwrap(); + engine.reload(&fake.store, 3_000).unwrap(); + assert!( + engine.runtime.watched_for("au-frozen").is_empty(), + "lifting the exclusion must also clear the filtered frozen set" + ); + crate::automation_engine::loops::targeting_tick(&engine, &host, 3_000); + assert_eq!( + engine.runtime.watched_for("au-frozen"), + HashSet::from(["tm-a".to_string(), "tm-b".to_string()]), + "the original matched terminal returns after a real save and reload" + ); + } + // ----------------------------------------------------------------------------------------- // §7.8 — completion is an in-memory event first // ----------------------------------------------------------------------------------------- From 7fcd7531338e77b33f7fcb3b5cbea2b9a4e4b86e Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 14:22:50 -0500 Subject: [PATCH 03/36] feat(automation): preview resolved target ids --- src-tauri/src/automation/roster.rs | 17 +++++- src-tauri/src/automation/targeting.rs | 83 ++++++++++++++++++++++----- src-tauri/src/automation_commands.rs | 45 ++++++++++++++- src-tauri/src/lib.rs | 1 + src/renderer/api/tauri-bridge.ts | 7 ++- src/renderer/types/electron.d.ts | 19 +++++- 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/automation/roster.rs b/src-tauri/src/automation/roster.rs index 4d18f5e..d953efb 100644 --- a/src-tauri/src/automation/roster.rs +++ b/src-tauri/src/automation/roster.rs @@ -54,7 +54,7 @@ pub struct TargetSnapshot { } /// One picker row. -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct WatchableTerminal { pub terminal_id: String, @@ -63,6 +63,17 @@ pub struct WatchableTerminal { pub shell: Option, pub pid: Option, pub cwd: Option, + /// The tab/pane label the targeting resolver reads for `Tab name contains`. This is not the + /// terminal's internal `name`: renderer-created terminals all share a `Terminal-{shell}` name, + /// so that would make a shell name look like a tab title. + pub display_label: Option, + /// The foreground process chain the targeting resolver reads for `Command contains`. + /// + /// The editor sends its roster back to the preview command, rather than implementing a second + /// matcher in TypeScript. Keeping the chain on this DTO is what lets that command use the same + /// resolver as the evaluator. + #[serde(default)] + pub command_lines: Vec, /// `false` means "not open right now" — **dormant, never dead**. Session restore re-registers the /// same `tm-` under a new `pc-`, so absence is not death. pub alive: bool, @@ -101,6 +112,8 @@ pub fn build( shell: Some(row.shell.clone()), pid: Some(row.pid), cwd: row.cwd.clone().or_else(|| snap.and_then(|s| s.folder.clone())), + display_label: row.display_label.clone(), + command_lines: row.command_lines.clone(), alive: true, }); } @@ -117,6 +130,8 @@ pub fn build( shell: None, pid: None, cwd: snap.and_then(|s| s.folder.clone()), + display_label: None, + command_lines: Vec::new(), alive: false, }); } diff --git a/src-tauri/src/automation/targeting.rs b/src-tauri/src/automation/targeting.rs index 6199fef..285bbb5 100644 --- a/src-tauri/src/automation/targeting.rs +++ b/src-tauri/src/automation/targeting.rs @@ -121,6 +121,50 @@ pub fn resolve(criterion: Criterion, value: &str, rows: &[RosterRow]) -> BTreeSe .collect() } +/// The three target sets the editor needs to explain a rule. +/// +/// `matched` is the unfiltered target set, `excluded` is the part of that set the rule filters +/// out, and `watching` is the remainder. They are sets rather than counts because matching the +/// wrong terminal can leave every displayed count unchanged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetResolution { + pub matched: BTreeSet, + pub excluded: BTreeSet, + pub watching: BTreeSet, +} + +/// Resolve one rule's target sets for both the evaluator and the editor preview. +/// +/// `previous` preserves the evaluator's frozen-set behaviour. The preview passes `None`, which +/// answers what the draft resolves to now; the evaluator passes its stored set. Keeping those two +/// callers here gives them one matcher and one exclusion gate. +pub fn resolve_target_sets( + rule: &AutomationRule, + rows: &[RosterRow], + previous: Option<&BTreeSet>, +) -> TargetResolution { + let matched = match rule.target_mode { + TargetMode::Pinned => rule.target_ids.iter().cloned().collect(), + TargetMode::Rule => match (rule.follow_new, previous) { + (false, Some(frozen)) if !frozen.is_empty() => frozen.clone(), + _ => resolve(rule.criterion, &rule.criterion_value, rows), + }, + }; + + let excluded = if rule.target_mode == TargetMode::Rule { + let mut candidates: BTreeSet = rule.excluded_ids.iter().cloned().collect(); + if let Some(criterion) = rule.exclude_criterion { + candidates.extend(resolve(criterion, &rule.exclude_criterion_value, rows)); + } + matched.intersection(&candidates).cloned().collect() + } else { + BTreeSet::new() + }; + let watching = matched.difference(&excluded).cloned().collect(); + + TargetResolution { matched, excluded, watching } +} + /// What changed between two ticks of the watched set. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SetDelta { @@ -165,21 +209,7 @@ pub fn watched_set( rows: &[RosterRow], previous: Option<&BTreeSet>, ) -> BTreeSet { - let mut watched = match rule.target_mode { - TargetMode::Pinned => rule.target_ids.iter().cloned().collect(), - TargetMode::Rule => match (rule.follow_new, previous) { - (false, Some(frozen)) if !frozen.is_empty() => frozen.clone(), - _ => resolve(rule.criterion, &rule.criterion_value, rows), - }, - }; - if rule.target_mode == TargetMode::Rule { - let mut excluded: BTreeSet = rule.excluded_ids.iter().cloned().collect(); - if let Some(criterion) = rule.exclude_criterion { - excluded.extend(resolve(criterion, &rule.exclude_criterion_value, rows)); - } - watched.retain(|terminal_id| !excluded.contains(terminal_id)); - } - watched + resolve_target_sets(rule, rows, previous).watching } #[cfg(test)] @@ -617,6 +647,29 @@ mod tests { assert_eq!(ids(watched_set(&r, &rows, None)), vec!["tm-kept"]); } + /// Counts alone let the editor and the engine disagree while both "pass": excluding tm-b + /// instead of tm-c gives the same three numbers. Assert the SETS. + #[test] + fn the_preview_resolves_the_same_ids_the_engine_watches() { + let rows = [ + row(Some("tm-a"), None, None, Some("node worker-a")), + row(Some("tm-b"), None, None, Some("node worker-b")), + row(Some("tm-c"), None, None, Some("node worker-c")), + ]; + let mut r = rule(TargetMode::Rule, Criterion::CommandContains, "node", true); + r.excluded_ids = vec!["tm-b".into()]; + + let preview = resolve_target_sets(&r, &rows, None); + assert_eq!(ids(preview.matched), vec!["tm-a", "tm-b", "tm-c"]); + assert_eq!(ids(preview.excluded), vec!["tm-b"]); + assert_eq!(ids(preview.watching.clone()), vec!["tm-a", "tm-c"]); + assert_eq!( + ids(watched_set(&r, &rows, None)), + ids(preview.watching), + "the evaluator must use the preview's resolution, not merely have the same count" + ); + } + // ----------------------------------------------------------------------------------------- // §10.12b — the departure is reported once // ----------------------------------------------------------------------------------------- diff --git a/src-tauri/src/automation_commands.rs b/src-tauri/src/automation_commands.rs index b173ce4..3f016ab 100644 --- a/src-tauri/src/automation_commands.rs +++ b/src-tauri/src/automation_commands.rs @@ -25,6 +25,7 @@ use tauri::{Emitter, State}; use crate::automation::events::{ChangedPayload, AUTOMATION_CHANGED}; use crate::automation::roster::{TargetSnapshot, WatchableTerminal}; +use crate::automation::targeting::resolve_target_sets; use crate::automation_engine::dry::DryRunReport; use crate::automation_engine::host::EngineHost; use crate::automation_store::{ @@ -219,6 +220,48 @@ pub async fn list_watchable_terminals( .map_err(|e| e.to_string())? } +/// The ids a draft targets right now, split into its unfiltered match, the part excluded, and what +/// remains to watch. The renderer supplies the roster it is drawing so the preview and its picker +/// describe one snapshot; matching itself remains in Rust alongside the evaluator. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutomationTargetPreview { + pub matched: Vec, + pub excluded: Vec, + pub watching: Vec, +} + +#[tauri::command] +pub async fn preview_automation_targets( + rule: AutomationRule, + terminals: Vec, +) -> Result { + tokio::task::spawn_blocking(move || { + let rows = terminals + .into_iter() + .filter(|terminal| terminal.alive) + .map(|terminal| crate::automation::roster::RosterRow { + terminal_id: Some(terminal.terminal_id), + process_id: terminal.process_id.unwrap_or_default(), + name: String::new(), + shell: terminal.shell.unwrap_or_default(), + pid: terminal.pid.unwrap_or_default(), + display_label: terminal.display_label, + cwd: terminal.cwd, + command_lines: terminal.command_lines, + }) + .collect::>(); + let resolved = resolve_target_sets(&rule, &rows, None); + Ok(AutomationTargetPreview { + matched: resolved.matched.into_iter().collect(), + excluded: resolved.excluded.into_iter().collect(), + watching: resolved.watching.into_iter().collect(), + }) + }) + .await + .map_err(|e| e.to_string())? +} + /// Which pairs `Re-arm now` touches: one named leaf, or every leaf the rule watches. /// /// **A named leaf is filtered through the watch set too.** It arrives from a renderer that may be @@ -890,7 +933,7 @@ mod source_tests { fn every_command_in_this_module_is_registered_in_lib() { let lib = crate::automation_engine::test_host::strip_comments(include_str!("lib.rs")); let names: Vec = command_bodies().into_iter().map(|(n, _)| n).collect(); - assert_eq!(names.len(), 14, "the command list changed: {:?}", names); + assert_eq!(names.len(), 15, "the command list changed: {:?}", names); for name in &names { assert!( lib.contains(&format!("automation_commands::{},", name)), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3122cbe..962c8d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1499,6 +1499,7 @@ pub fn run() { automation_commands::get_automation_runtime, automation_commands::load_automation_log, automation_commands::list_watchable_terminals, + automation_commands::preview_automation_targets, automation_commands::dry_run_automation, automation_commands::save_automation, automation_commands::add_automation_target, diff --git a/src/renderer/api/tauri-bridge.ts b/src/renderer/api/tauri-bridge.ts index 7b8382b..4115f90 100644 --- a/src/renderer/api/tauri-bridge.ts +++ b/src/renderer/api/tauri-bridge.ts @@ -3,7 +3,7 @@ import { listen } from '@tauri-apps/api/event'; import type { UnlistenFn } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { open as openFileDialog, save as saveFileDialog } from '@tauri-apps/plugin-dialog'; -import type { TerminalSnapshot, ActiveProcess, PeerInfo, PeerRequestInfo, PairingCode, FabricStatus, GrantLevel, AutomationRule, AutomationLogEntry, AutomationSaveResult, WatchableTerminal, DryRunReport } from '../types/electron'; +import type { TerminalSnapshot, ActiveProcess, PeerInfo, PeerRequestInfo, PairingCode, FabricStatus, GrantLevel, AutomationRule, AutomationLogEntry, AutomationSaveResult, WatchableTerminal, AutomationTargetPreview, DryRunReport } from '../types/electron'; import type { AutomationStatePayload } from '../services/automationEvents'; import { shouldHandleForWindow } from './windowRouting'; import { emitPtyInput } from '../utils/ptyInputSignal'; @@ -222,6 +222,7 @@ interface ElectronAPI { getAutomationRuntime: () => Promise; loadAutomationLog: (ruleId: string | null, newestFirst: boolean, limit: number) => Promise; listWatchableTerminals: (ruleId: string | null, includeIds: string[] | null) => Promise; + previewAutomationTargets: (rule: AutomationRule, terminals: WatchableTerminal[]) => Promise; dryRunAutomation: (rule: AutomationRule, terminalId: string) => Promise; saveAutomation: (rule: AutomationRule, origin: string) => Promise; deleteAutomation: (id: string, origin: string) => Promise; @@ -893,7 +894,7 @@ const tauriBridge: ElectronAPI = { // --- Terminal Automations (Plan 028) --- // // Thin `invoke` wrappers over `automation_commands.rs`, one per command. The first - // eleven follow that file's declaration order; the three id-only writers that replaced a + // twelve follow that file's declaration order; the three id-only writers that replaced a // whole-rule `saveAutomation` are appended last here as the newest of them, wherever // they sit in the Rust module. Every argument name // here is the camelCase form Tauri derives from the Rust parameter — `rule_id` on the @@ -906,6 +907,8 @@ const tauriBridge: ElectronAPI = { invoke('load_automation_log', { ruleId, newestFirst, limit }), listWatchableTerminals: async (ruleId, includeIds) => invoke('list_watchable_terminals', { ruleId, includeIds }), + previewAutomationTargets: async (rule, terminals) => + invoke('preview_automation_targets', { rule, terminals }), dryRunAutomation: async (rule, terminalId) => invoke('dry_run_automation', { rule, terminalId }), saveAutomation: async (rule, origin) => diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 162952b..a73e187 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -385,8 +385,8 @@ export interface ElectronAPI { // (the browser host is a no-op). setKeepRunningInBackground?: (enabled: boolean) => Promise; - // Terminal Automations (Plan 028) — the fourteen commands of `automation_commands.rs`. - // The first eleven are in the order that file declares them; the three id-only writers + // Terminal Automations (Plan 028) — the fifteen commands of `automation_commands.rs`. + // The first twelve are in the order that file declares them; the three id-only writers // that replaced a whole-rule `saveAutomation` — `addAutomationTarget`, // `removeAutomationTarget`, `setAutomationVerbose` — are listed last here as the newest // of them. All optional and all Tauri-only: the store is @@ -404,6 +404,10 @@ export interface ElectronAPI { ruleId: string | null, includeIds: string[] | null, ) => Promise; + previewAutomationTargets?: ( + rule: AutomationRule, + terminals: WatchableTerminal[], + ) => Promise; dryRunAutomation?: (rule: AutomationRule, terminalId: string) => Promise; saveAutomation?: (rule: AutomationRule, origin: string) => Promise; deleteAutomation?: (id: string, origin: string) => Promise; @@ -791,5 +795,16 @@ export interface WatchableTerminal { pid?: number | null; /** The working folder, from OSC or the process snapshot. `folder` in §7.8's prose. */ cwd?: string | null; + /** The tab/pane label that `Tab name contains` resolves against. */ + displayLabel?: string | null; + /** The foreground process chain that `Command contains` resolves against. */ + commandLines?: string[]; alive: boolean; } + +/** The backend-owned target resolution the editor renders; every field is an id list, never a count. */ +export interface AutomationTargetPreview { + matched: string[]; + excluded: string[]; + watching: string[]; +} From 730f9fb40d8ecccdce15886485f7f3e8bae5a3a0 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 14:31:59 -0500 Subject: [PATCH 04/36] feat(automation): validate exclusion criteria --- src-tauri/src/automation_validation.rs | 27 ++- .../automationValidationCases.json | 155 ++++++++++++++++++ .../__tests__/automationValidation.test.ts | 14 +- .../Automation/automationValidation.ts | 43 ++++- src/renderer/types/electron.d.ts | 6 + 5 files changed, 232 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/automation_validation.rs b/src-tauri/src/automation_validation.rs index 9cd334b..2c999c3 100644 --- a/src-tauri/src/automation_validation.rs +++ b/src-tauri/src/automation_validation.rs @@ -517,6 +517,14 @@ in the message, as $2, $3 and so on.", /// worse than one that says so. pub const MIN_TIMER_MS: i64 = crate::automation_engine::due::EVENT_MIN_INTERVAL_MS; +/// Which terminal criteria have a companion value field to fill in. +/// +/// `AllTerminals` is the one selector that deliberately needs no value. Both the watched-set +/// criterion and its optional exclusion use this same predicate so the two controls cannot drift. +fn criterion_needs_value(criterion: Criterion) -> bool { + !matches!(criterion, Criterion::AllTerminals) +} + /// Everything wrong with a WHOLE rule — §6.5's five categories: **target, interval, pattern, /// threshold, message**. /// @@ -547,8 +555,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { } } TargetMode::Rule => { - let needs_value = !matches!(rule.criterion, Criterion::AllTerminals); - if needs_value && rule.criterion_value.trim().is_empty() { + if criterion_needs_value(rule.criterion) && rule.criterion_value.trim().is_empty() { out.push(Problem::new( Severity::Blocks, "targets", @@ -556,6 +563,19 @@ pub fn problems(rule: &AutomationRule) -> Vec { "Fill in what the terminals must match, or watch all terminals instead.", )); } + + if rule + .exclude_criterion + .is_some_and(criterion_needs_value) + && rule.exclude_criterion_value.trim().is_empty() + { + out.push(Problem::new( + Severity::Blocks, + "targets", + "targets.excludeValueEmpty", + "Fill in what the exclusion must match, or exclude all terminals instead.", + )); + } } } @@ -1058,7 +1078,7 @@ mod tests { // about. The count is a floor, not the exact number, so adding a case is not a two-file // edit. assert!( - fixture.cases.len() >= 20, + fixture.cases.len() >= 66, "the shared fixture has shrunk to {} cases", fixture.cases.len() ); @@ -1092,6 +1112,7 @@ mod tests { for code in [ "targets.empty", "targets.criterion", + "targets.excludeValueEmpty", "monitor.interval", "parse.empty", "parse.uncompilable", diff --git a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json index 7d37d60..b0f765b 100644 --- a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json +++ b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json @@ -259,6 +259,161 @@ }, "expected": [] }, + { + "name": "an exclusion criterion with no value blocks", + "why": "The exclusion is another criterion, so selectors that need a value cannot silently match everything.", + "rule": { + "id": "au-fix", + "name": "r", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "excludedIds": [], + "excludeCriterion": "commandContains", + "excludeCriterionValue": " ", + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "monitor": { + "read": "newOutput", + "cadence": "onOutput", + "everyMs": 30000 + }, + "parse": { + "preset": "custom", + "literal": null, + "find": "ctx:(\\d+)%", + "keep": "brackets" + }, + "cond": { + "kind": "number", + "op": "gt", + "threshold": 25 + }, + "action": { + "message": "go", + "sendTo": "matched", + "submit": true, + "cliType": "default" + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "targets", + "code": "targets.excludeValueEmpty" + } + ] + }, + { + "name": "an all-terminals exclusion needs no criterion value", + "why": "AllTerminals is the selector without a companion value, for the exclusion just as for the watched set.", + "rule": { + "id": "au-fix", + "name": "r", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "excludedIds": [], + "excludeCriterion": "allTerminals", + "excludeCriterionValue": "", + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "monitor": { + "read": "newOutput", + "cadence": "onOutput", + "everyMs": 30000 + }, + "parse": { + "preset": "custom", + "literal": null, + "find": "ctx:(\\d+)%", + "keep": "brackets" + }, + "cond": { + "kind": "number", + "op": "gt", + "threshold": 25 + }, + "action": { + "message": "go", + "sendTo": "matched", + "submit": true, + "cliType": "default" + } + } + }, + "expected": [] + }, + { + "name": "a pinned rule ignores its hidden exclusion criterion", + "why": "Pinned-mode state may retain a rule exclusion, but no rule criterion participates until the user switches modes.", + "rule": { + "id": "au-fix", + "name": "r", + "enabled": false, + "runsOnce": false, + "targetMode": "pinned", + "criterion": "commandContains", + "criterionValue": "claude", + "followNew": true, + "targetIds": [ + "tm-a71f3c92k" + ], + "excludedIds": [], + "excludeCriterion": "commandContains", + "excludeCriterionValue": " ", + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "monitor": { + "read": "newOutput", + "cadence": "onOutput", + "everyMs": 30000 + }, + "parse": { + "preset": "custom", + "literal": null, + "find": "ctx:(\\d+)%", + "keep": "brackets" + }, + "cond": { + "kind": "number", + "op": "gt", + "threshold": 25 + }, + "action": { + "message": "go", + "sendTo": "matched", + "submit": true, + "cliType": "default" + } + } + }, + "expected": [] + }, { "name": "a timer faster than the engine's floor blocks", "why": "`due_now` clamps to 250 ms, so a rule asking for 100 would silently get 250.", diff --git a/src/renderer/components/Automation/__tests__/automationValidation.test.ts b/src/renderer/components/Automation/__tests__/automationValidation.test.ts index 5120c25..9ccac36 100644 --- a/src/renderer/components/Automation/__tests__/automationValidation.test.ts +++ b/src/renderer/components/Automation/__tests__/automationValidation.test.ts @@ -67,7 +67,7 @@ describe('automationValidation — the shared fixture', () => { // "at least 20" and this test would stay green. `all.length` is DERIVED from `BADGES` // (never hand-typed), so bumping this number is the one place a new code cannot be added // silently — it forces a look at whether the fixture actually covers it. - expect(all.length).toBe(22); + expect(all.length).toBe(23); expect(all.filter((code) => !covered.has(code))).toEqual([]); }); }); @@ -84,6 +84,18 @@ describe('automationValidation — the words the user reads', () => { 'Pick at least one terminal for this rule to watch.', ); + const noExclusionValue: AutomationRule = { + ...base(), + targetMode: 'rule', + criterion: 'allTerminals', + criterionValue: '', + excludeCriterion: 'commandContains', + excludeCriterionValue: ' ', + }; + expect(find(noExclusionValue, 'targets.excludeValueEmpty')?.message).toBe( + 'Fill in what the exclusion must match, or exclude all terminals instead.', + ); + const noPattern: AutomationRule = { ...base(), graph: { ...base().graph, parse: { ...base().graph.parse, find: '' } }, diff --git a/src/renderer/components/Automation/automationValidation.ts b/src/renderer/components/Automation/automationValidation.ts index a687d18..3d692fb 100644 --- a/src/renderer/components/Automation/automationValidation.ts +++ b/src/renderer/components/Automation/automationValidation.ts @@ -27,6 +27,7 @@ */ import type { AutomationClause, + AutomationCriterion, AutomationGraph, AutomationParseStep, AutomationRule, @@ -50,6 +51,7 @@ export type ProblemField = 'targets' | 'monitor' | 'parse' | 'cond' | 'timer' | export type ProblemCode = | 'targets.empty' | 'targets.criterion' + | 'targets.excludeValueEmpty' | 'monitor.interval' | 'parse.empty' | 'parse.uncompilable' @@ -95,6 +97,10 @@ const problem = ( message: string, ): Problem => ({ severity, field, code, message }); +/** `allTerminals` is the one selector that deliberately has no companion value field. */ +const criterionNeedsValue = (criterion: AutomationCriterion): boolean => + criterion !== 'allTerminals'; + /** * Compile a user pattern the way the browser will run it for the live preview. * @@ -620,15 +626,33 @@ export function problems(rule: AutomationRule): Problem[] { ), ); } - } else if (rule.criterion !== 'allTerminals' && rule.criterionValue.trim().length === 0) { - out.push( - problem( - 'blocks', - 'targets', - 'targets.criterion', - 'Fill in what the terminals must match, or watch all terminals instead.', - ), - ); + } else { + if (criterionNeedsValue(rule.criterion) && rule.criterionValue.trim().length === 0) { + out.push( + problem( + 'blocks', + 'targets', + 'targets.criterion', + 'Fill in what the terminals must match, or watch all terminals instead.', + ), + ); + } + + if ( + rule.excludeCriterion !== null + && rule.excludeCriterion !== undefined + && criterionNeedsValue(rule.excludeCriterion) + && (rule.excludeCriterionValue ?? '').trim().length === 0 + ) { + out.push( + problem( + 'blocks', + 'targets', + 'targets.excludeValueEmpty', + 'Fill in what the exclusion must match, or exclude all terminals instead.', + ), + ); + } } // --- interval -------------------------------------------------------------------------------- @@ -804,6 +828,7 @@ export function problemsFor(list: Problem[], field: ProblemField): Problem[] { export const BADGES: Record = { 'targets.empty': 'needs terminals', 'targets.criterion': 'needs something to match', + 'targets.excludeValueEmpty': 'needs something to exclude', 'monitor.interval': 'checks too often', 'parse.empty': 'needs a pattern', 'parse.uncompilable': 'pattern not understood', diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index a73e187..b01ca8c 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -672,6 +672,12 @@ export interface AutomationRule { followNew: boolean; /** Durable `tm-` leaves. Never `pc-` process ids, which are per-run. */ targetIds: string[]; + /** Rule-mode terminal ids removed after the criterion resolves. */ + excludedIds?: string[]; + /** An optional rule-mode selector whose matches are removed from the watched set. */ + excludeCriterion?: AutomationCriterion | null; + /** The optional selector's value; `allTerminals` deliberately leaves it blank. */ + excludeCriterionValue?: string; completedAt?: number | null; verboseUntil?: number | null; From 844d8a6e314ae98b5837ed6ce385da43fad8789d Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 14:39:27 -0500 Subject: [PATCH 05/36] feat(automation): add exclusions panel --- .../__tests__/automationExclusions.test.tsx | 170 ++++++++++++++++++ .../components/Automation/automationDraft.ts | 36 +++- .../Automation/panels/MonitorPanel.tsx | 96 ++++++++-- 3 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 src/renderer/components/Automation/__tests__/automationExclusions.test.tsx diff --git a/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx b/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx new file mode 100644 index 0000000..28e1c9c --- /dev/null +++ b/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx @@ -0,0 +1,170 @@ +/** + * @jest-environment jsdom + * + * The rule-mode exclusions panel consumes the backend preview rather than recreating targeting in + * the renderer. The id sets below are Task 3's `resolve_target_sets` fixture: counts alone would + * also pass if the editor removed tm-c while the engine removed tm-b. + */ +import React, { act } from 'react'; +import { createRoot, Root } from 'react-dom/client'; + +import { AuInspector } from '../AuInspector'; +import { draftFromRule } from '../automationDraft'; +import { problems } from '../automationValidation'; +import { blankDraft } from '../../Settings/Automations/automationTemplates'; +import type { + AutomationRule, + AutomationTargetPreview, + WatchableTerminal, +} from '../../../types/electron'; + +const TERMINALS: WatchableTerminal[] = [ + { terminalId: 'tm-a', label: 'worker a', commandLines: ['node worker-a'], alive: true }, + { terminalId: 'tm-b', label: 'worker b', commandLines: ['node worker-b'], alive: true }, + { terminalId: 'tm-c', label: 'worker c', commandLines: ['node worker-c'], alive: true }, +]; + +function rule(over: Partial = {}): AutomationRule { + const blank = blankDraft(); + return { + ...blank, + id: 'au-exclusions', + name: 'Node workers', + criterion: 'commandContains', + criterionValue: 'node', + graph: { + ...blank.graph, + parse: { preset: 'custom', literal: null, find: 'ready', keep: 'whole' }, + action: { ...blank.graph.action, message: 'continue' }, + }, + ...over, + }; +} + +interface Api { + previewAutomationTargets: jest.Mock; +} + +function installPreview(preview: AutomationTargetPreview): Api { + const api: Api = { previewAutomationTargets: jest.fn(() => Promise.resolve(preview)) }; + (window as unknown as { electronAPI: Api }).electronAPI = api; + return api; +} + +describe('the exclusions panel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + async function show(next: AutomationRule) { + const draft = { ...draftFromRule(next), selected: 'monitor' as const }; + await act(async () => { + root.render( + {}} + onFocusStep={() => {}} + dispatch={() => {}} + />, + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + } + + const exclusionLabel = () => [...container.querySelectorAll('.au-flabel')] + .find((label) => label.textContent === 'Except these'); + + it('shows the exclusions panel only in rule mode', async () => { + installPreview({ matched: [], excluded: [], watching: [] }); + await show(rule()); + expect(exclusionLabel()).toBeDefined(); + expect(container.querySelector('[aria-label="What the exception must match"]')).not.toBeNull(); + + await show(rule({ targetMode: 'pinned', targetIds: ['tm-a'] })); + expect(exclusionLabel()).toBeUndefined(); + expect(container.querySelector('[aria-label="What the exception must match"]')).toBeNull(); + }); + + it('renders matched, excluded and watching as three separate numbers', async () => { + installPreview({ + matched: ['tm-a', 'tm-b', 'tm-c', 'tm-d'], + excluded: ['tm-b', 'tm-d'], + watching: ['tm-a', 'tm-c'], + }); + await show(rule({ excludedIds: ['tm-b'] })); + + const count = container.querySelector('.au-termcount'); + expect(count?.textContent).toBe('Matching 4 - excluded 2 = watching 2'); + const numbers = count === null ? [] : [...count.querySelectorAll('.au-n')]; + expect(numbers.map((n) => n.textContent)).toEqual(['4', '2', '2']); + }); + + it('does not offer exclusions on a hand-picked target set', async () => { + await show(rule({ + targetMode: 'pinned', + targetIds: ['tm-a'], + excludedIds: ['tm-b'], + excludeCriterion: 'commandContains', + excludeCriterionValue: 'node', + })); + + expect(exclusionLabel()).toBeUndefined(); + expect(container.textContent).not.toContain('anything matching this exception'); + expect(container.querySelector('[aria-label="What the exception must match"]')).toBeNull(); + }); + + it('previews exactly the ids the engine would watch', async () => { + const enginePreview: AutomationTargetPreview = { + matched: ['tm-a', 'tm-b', 'tm-c'], + excluded: ['tm-b'], + watching: ['tm-a', 'tm-c'], + }; + const api = installPreview(enginePreview); + await show(rule({ excludedIds: ['tm-b'] })); + + expect(api.previewAutomationTargets).toHaveBeenCalledWith( + expect.objectContaining({ + targetMode: 'rule', + criterion: 'commandContains', + criterionValue: 'node', + excludedIds: ['tm-b'], + }), + TERMINALS, + ); + + // The ID sets are the oracle. These assertions intentionally precede the count assertion: + // excluding tm-c instead of tm-b would still produce 3 - 1 = 2. + const preview = await api.previewAutomationTargets.mock.results[0].value; + expect(preview.matched).toEqual(['tm-a', 'tm-b', 'tm-c']); + expect(preview.excluded).toEqual(['tm-b']); + expect(preview.watching).toEqual(['tm-a', 'tm-c']); + + expect(container.querySelector('.au-termcount')?.textContent) + .toBe('Matching 3 - excluded 1 = watching 2'); + }); +}); diff --git a/src/renderer/components/Automation/automationDraft.ts b/src/renderer/components/Automation/automationDraft.ts index 3454bf1..34b92f1 100644 --- a/src/renderer/components/Automation/automationDraft.ts +++ b/src/renderer/components/Automation/automationDraft.ts @@ -567,11 +567,11 @@ export function isDirty(draft: AutomationDraft): boolean { /** * The rule as a string, with the one field whose ORDER means nothing put in a fixed one. * - * `targetIds` is a set: `write_rule` replaces the pick set row by row and the engine resolves it - * with a lookup, so nothing downstream can tell `['tm-1','tm-2']` from `['tm-2','tm-1']`. The - * picker's toggle appends, though, so unticking a terminal and ticking it straight back rotated the - * array — and the draft then read dirty forever, with a *Leave without saving?* dialog over an - * identical rule. + * `targetIds` and `excludedIds` are sets: `write_rule` replaces each set row by row and the engine + * resolves them with a lookup, so nothing downstream can tell `['tm-1','tm-2']` from + * `['tm-2','tm-1']`. The picker's toggle appends, though, so unticking a terminal and ticking it + * straight back rotated the array — and the draft then read dirty forever, with a *Leave without + * saving?* dialog over an identical rule. * * **Both sides go through this**, which is the whole point: normalising one side of a comparison and * not the other can only invent differences (`transform-on-one-side-of-a-comparison`). And it @@ -593,7 +593,12 @@ function comparable(rule: AutomationRule): string { ), } : rule.graph; - return JSON.stringify({ ...rule, graph, targetIds: [...rule.targetIds].sort() }); + return JSON.stringify({ + ...rule, + graph, + targetIds: [...rule.targetIds].sort(), + excludedIds: [...(rule.excludedIds ?? [])].sort(), + }); } export type DraftAction = @@ -613,6 +618,10 @@ export type DraftAction = | { type: 'followNew'; followNew: boolean } | { type: 'targets'; ids: string[] } | { type: 'toggleTarget'; id: string } + | { type: 'excludedTargets'; ids: string[] } + | { type: 'toggleExcludedTarget'; id: string } + | { type: 'excludeCriterion'; criterion: AutomationRule['criterion'] | null } + | { type: 'excludeCriterionValue'; value: string } | { type: 'monitor'; patch: Partial } | { type: 'preset'; preset: AutomationParseStep['preset'] } | { type: 'literal'; literal: string } @@ -759,6 +768,21 @@ export function draftReducer(draft: AutomationDraft, action: DraftAction): Autom ? rule.targetIds.filter((id) => id !== action.id) : [...rule.targetIds, action.id], }); + case 'excludedTargets': + return withRule(draft, { ...rule, excludedIds: [...action.ids] }); + case 'toggleExcludedTarget': { + const excludedIds = rule.excludedIds ?? []; + return withRule(draft, { + ...rule, + excludedIds: excludedIds.includes(action.id) + ? excludedIds.filter((id) => id !== action.id) + : [...excludedIds, action.id], + }); + } + case 'excludeCriterion': + return withRule(draft, { ...rule, excludeCriterion: action.criterion }); + case 'excludeCriterionValue': + return withRule(draft, { ...rule, excludeCriterionValue: action.value }); // **A patch to a step the rule does not have is a no-op, never a materialisation.** Plan // 032 §3.1 lets a schedule rule carry no monitor/parse/cond at all, and these six actions // come from panels that are only mounted for a step the rule HAS. Filling the gap in from diff --git a/src/renderer/components/Automation/panels/MonitorPanel.tsx b/src/renderer/components/Automation/panels/MonitorPanel.tsx index d632cae..ada5346 100644 --- a/src/renderer/components/Automation/panels/MonitorPanel.tsx +++ b/src/renderer/components/Automation/panels/MonitorPanel.tsx @@ -6,7 +6,11 @@ * here, because they are one decision. */ import React from 'react'; -import type { AutomationCriterion, WatchableTerminal } from '../../../types/electron'; +import type { + AutomationCriterion, + AutomationTargetPreview, + WatchableTerminal, +} from '../../../types/electron'; import type { AutomationDraft, DraftAction } from '../automationDraft'; import type { PanelModel } from '../automationDerive'; import { AuTerminalPicker } from '../AuTerminalPicker'; @@ -47,7 +51,29 @@ export const MonitorPanel: React.FC = ({ }) => { const { rule } = draft; const { monitor } = rule.graph; - const matching = terminals.filter((t) => t.alive).length; + const [targetPreview, setTargetPreview] = React.useState(null); + const previewTargets = typeof window === 'undefined' + ? undefined + : window.electronAPI?.previewAutomationTargets; + + React.useEffect(() => { + if (rule.targetMode !== 'rule' || !previewTargets) { + setTargetPreview(null); + return undefined; + } + + let current = true; + void previewTargets(rule, terminals) + .then((preview) => { + if (current) setTargetPreview(preview); + }) + .catch(() => { + if (current) setTargetPreview(null); + }); + return () => { + current = false; + }; + }, [previewTargets, rule, terminals]); return ( <> @@ -112,16 +138,62 @@ export const MonitorPanel: React.FC = ({ -
- Open right now {matching} - · refreshed every few seconds -
- - This count is every terminal that is open, not every terminal this rule - matches — matching is decided in the engine, against the command line and - working folder it can see, and the rule's own row reports what it - actually watches. - + + dispatch({ type: 'toggleExcludedTarget', id })} + onSet={(ids) => dispatch({ type: 'excludedTargets', ids })} + /> + + + +
+ + {rule.excludeCriterion != null + && rule.excludeCriterion !== 'allTerminals' && ( + + dispatch({ type: 'excludeCriterionValue', value: e.target.value })} + /> + )} +
+
+ + {targetPreview === null ? ( +
Resolving matching terminals…
+ ) : ( +
+ Matching {targetPreview.matched.length} + {' - '}excluded {targetPreview.excluded.length} + {' = '}watching {targetPreview.watching.length} +
+ )} ) : ( Date: Sun, 6 Sep 2026 14:57:46 -0500 Subject: [PATCH 06/36] fix(automation): scan exclusion criteria --- src-tauri/src/automation_commands.rs | 15 +++------ src-tauri/src/automation_engine/loops.rs | 31 ++++++++++++++++++- src-tauri/src/automation_engine/test_host.rs | 29 +++++++++++++++-- src-tauri/src/automation_store.rs | 21 +++++++++++++ src/renderer/api/tauri-bridge.ts | 8 ++--- .../Automation/AutomationEditor.tsx | 17 ++++++++-- .../automationEditorLifecycle.test.tsx | 22 +++++++++++++ .../__tests__/automationExclusions.test.tsx | 8 +++++ .../Automation/panels/MonitorPanel.tsx | 1 + src/renderer/types/electron.d.ts | 1 + 10 files changed, 134 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/automation_commands.rs b/src-tauri/src/automation_commands.rs index 3f016ab..93a1952 100644 --- a/src-tauri/src/automation_commands.rs +++ b/src-tauri/src/automation_commands.rs @@ -176,20 +176,15 @@ pub async fn list_watchable_terminals( state: State<'_, AppState>, rule_id: Option, include_ids: Option>, + criteria: Vec, ) -> Result, String> { let owned = state.inner().clone(); tokio::task::spawn_blocking(move || { let store = owned.automation_store.clone(); - // Only the criterion of the rule being edited, so opening the picker on a `Terminal ID is` - // rule never enumerates the machine's processes (§10.13). - let criteria: Vec = match rule_id.as_deref() { - Some(id) => store - .get_rule(id) - .map_err(to_string_err)? - .map(|r| vec![r.criterion]) - .unwrap_or_default(), - None => Vec::new(), - }; + // The editor supplies its CURRENT rule-mode criteria, including the exception. `rule_id` + // scopes only the persisted label snapshots below: a saved row is stale while its draft is + // being edited, and a new draft has no saved row at all. Keeping those jobs separate means + // a command/cwd criterion gets the scan it needs before preview resolution. let live = EngineHost::roster(&owned, &criteria); // §4.3: scoped to the rule when the caller names one — the editor always knows which rule it diff --git a/src-tauri/src/automation_engine/loops.rs b/src-tauri/src/automation_engine/loops.rs index 88a06ee..839bb27 100644 --- a/src-tauri/src/automation_engine/loops.rs +++ b/src-tauri/src/automation_engine/loops.rs @@ -1075,7 +1075,7 @@ pub fn targeting_tick( let criteria: Vec = rules .iter() .filter(|l| l.rule.target_mode == TargetMode::Rule) - .map(|l| l.rule.criterion) + .flat_map(|l| std::iter::once(l.rule.criterion).chain(l.rule.exclude_criterion)) .collect(); let rows = host.roster(&criteria); // Indexed once, outside the rule loop: the snapshot walk below wants the row for a terminal it @@ -4107,6 +4107,35 @@ mod tests { ); } + /// An exception is a criterion the roster must answer too. The fake deliberately exposes its + /// process-derived command lines only when `roster` was asked for `CommandContains`, mirroring + /// the production scan gate. Reverting the exclusion half of the criteria collection leaves the + /// line empty, so `tm-1` incorrectly remains watched and this test fails. + #[test] + fn targeting_requests_and_uses_command_lines_for_an_exclusion_criterion() { + let mut rule = ctx_rule("au-exclude-claude"); + rule.criterion = Criterion::AllTerminals; + rule.exclude_criterion = Some(Criterion::CommandContains); + rule.exclude_criterion_value = "claude".into(); + let (engine, fake, host) = wire(vec![rule]); + fake.scanned_command_lines + .lock() + .unwrap() + .insert("tm-1".into(), vec!["pwsh.exe -Command claude".into()]); + + let pass = targeting_tick(&engine, &host, 1_000); + + assert!( + fake.last_roster_criteria().contains(&Criterion::CommandContains), + "the exclusion must request the process scan that populates command lines" + ); + assert_eq!( + pass.watched.get("au-exclude-claude"), + Some(&HashSet::new()), + "the populated command line must make the exception remove tm-1" + ); + } + /// **`if report.emit` written as `if false` changed nothing any test could see**, in either of /// the two places it was written. The decision now has one implementation, out where a test can /// reach it. diff --git a/src-tauri/src/automation_engine/test_host.rs b/src-tauri/src/automation_engine/test_host.rs index 2c13ce8..fadebeb 100644 --- a/src-tauri/src/automation_engine/test_host.rs +++ b/src-tauri/src/automation_engine/test_host.rs @@ -31,6 +31,11 @@ pub(crate) struct FakeHost { pub(crate) store: Arc, pub(crate) leaves: Mutex>, pub(crate) roster: Mutex>, + /// Process-derived command lines, made visible only when the caller asks the roster for the + /// criterion that requires a process scan. This gives targeting tests the same request/populate + /// seam as `AppState::roster` without taking a real machine snapshot. + pub(crate) scanned_command_lines: Mutex>>, + pub(crate) roster_criteria: Mutex>>, pub(crate) text: Mutex>, /// Every `tail` the engine asked for, in order — **the screen reads, recorded**. /// @@ -67,6 +72,8 @@ impl FakeHost { store: Arc::new(AutomationStore::new_in_memory()), leaves: Mutex::new(HashMap::new()), roster: Mutex::new(Vec::new()), + scanned_command_lines: Mutex::new(HashMap::new()), + roster_criteria: Mutex::new(Vec::new()), text: Mutex::new(HashMap::new()), tails: Mutex::new(Vec::new()), writes: Mutex::new(Vec::new()), @@ -136,6 +143,10 @@ impl FakeHost { pub(crate) fn announced(&self) -> Vec { self.changed.lock().unwrap().iter().flatten().cloned().collect() } + + pub(crate) fn last_roster_criteria(&self) -> Vec { + self.roster_criteria.lock().unwrap().last().cloned().unwrap_or_default() + } } impl EngineHost for FakeHost { @@ -146,8 +157,22 @@ impl EngineHost for FakeHost { } self.leaves.lock().unwrap().get(tm).cloned() } - fn roster(&self, _criteria: &[Criterion]) -> Vec { - self.roster.lock().unwrap().clone() + fn roster(&self, criteria: &[Criterion]) -> Vec { + self.roster_criteria.lock().unwrap().push(criteria.to_vec()); + let mut rows = self.roster.lock().unwrap().clone(); + if criteria.contains(&Criterion::CommandContains) { + let scanned = self.scanned_command_lines.lock().unwrap(); + for row in &mut rows { + if let Some(lines) = row + .terminal_id + .as_deref() + .and_then(|terminal_id| scanned.get(terminal_id)) + { + row.command_lines = lines.clone(); + } + } + } + rows } fn live_processes(&self) -> Vec { self.leaves.lock().unwrap().values().cloned().collect() diff --git a/src-tauri/src/automation_store.rs b/src-tauri/src/automation_store.rs index 1221b33..d98d246 100644 --- a/src-tauri/src/automation_store.rs +++ b/src-tauri/src/automation_store.rs @@ -3221,6 +3221,27 @@ mod tests { assert!(store.get_rule("au-2").unwrap().is_some()); } + /// Reusing an id is the observable regression: without `DELETE FROM automation_exclusions`, a + /// newly saved rule silently inherits the deleted rule's exception rows. + #[test] + fn deleting_then_reusing_a_rule_id_does_not_restore_old_exclusions() { + let store = AutomationStore::new_in_memory(); + let mut original = rule("au-x"); + original.excluded_ids = vec!["tm-excluded-before-delete".into()]; + store.save_rule(&original).unwrap(); + + assert!(store.delete_rule("au-x").unwrap()); + + let replacement = rule("au-x"); + store.save_rule(&replacement).unwrap(); + let saved = store.get_rule("au-x").unwrap().unwrap(); + assert!( + saved.excluded_ids.is_empty(), + "the replacement must not inherit exclusions from the deleted rule: {:?}", + saved.excluded_ids + ); + } + #[test] fn mark_completed_stamps_the_rule_and_reports_a_missing_one() { let store = AutomationStore::new_in_memory(); diff --git a/src/renderer/api/tauri-bridge.ts b/src/renderer/api/tauri-bridge.ts index 4115f90..891b452 100644 --- a/src/renderer/api/tauri-bridge.ts +++ b/src/renderer/api/tauri-bridge.ts @@ -3,7 +3,7 @@ import { listen } from '@tauri-apps/api/event'; import type { UnlistenFn } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { open as openFileDialog, save as saveFileDialog } from '@tauri-apps/plugin-dialog'; -import type { TerminalSnapshot, ActiveProcess, PeerInfo, PeerRequestInfo, PairingCode, FabricStatus, GrantLevel, AutomationRule, AutomationLogEntry, AutomationSaveResult, WatchableTerminal, AutomationTargetPreview, DryRunReport } from '../types/electron'; +import type { TerminalSnapshot, ActiveProcess, PeerInfo, PeerRequestInfo, PairingCode, FabricStatus, GrantLevel, AutomationCriterion, AutomationRule, AutomationLogEntry, AutomationSaveResult, WatchableTerminal, AutomationTargetPreview, DryRunReport } from '../types/electron'; import type { AutomationStatePayload } from '../services/automationEvents'; import { shouldHandleForWindow } from './windowRouting'; import { emitPtyInput } from '../utils/ptyInputSignal'; @@ -221,7 +221,7 @@ interface ElectronAPI { listAutomations: () => Promise; getAutomationRuntime: () => Promise; loadAutomationLog: (ruleId: string | null, newestFirst: boolean, limit: number) => Promise; - listWatchableTerminals: (ruleId: string | null, includeIds: string[] | null) => Promise; + listWatchableTerminals: (ruleId: string | null, includeIds: string[] | null, criteria: AutomationCriterion[]) => Promise; previewAutomationTargets: (rule: AutomationRule, terminals: WatchableTerminal[]) => Promise; dryRunAutomation: (rule: AutomationRule, terminalId: string) => Promise; saveAutomation: (rule: AutomationRule, origin: string) => Promise; @@ -905,8 +905,8 @@ const tauriBridge: ElectronAPI = { getAutomationRuntime: async () => invoke('get_automation_runtime'), loadAutomationLog: async (ruleId, newestFirst, limit) => invoke('load_automation_log', { ruleId, newestFirst, limit }), - listWatchableTerminals: async (ruleId, includeIds) => - invoke('list_watchable_terminals', { ruleId, includeIds }), + listWatchableTerminals: async (ruleId, includeIds, criteria) => + invoke('list_watchable_terminals', { ruleId, includeIds, criteria }), previewAutomationTargets: async (rule, terminals) => invoke('preview_automation_targets', { rule, terminals }), dryRunAutomation: async (rule, terminalId) => diff --git a/src/renderer/components/Automation/AutomationEditor.tsx b/src/renderer/components/Automation/AutomationEditor.tsx index 373fad9..69d209e 100644 --- a/src/renderer/components/Automation/AutomationEditor.tsx +++ b/src/renderer/components/Automation/AutomationEditor.tsx @@ -218,6 +218,11 @@ export const AutomationEditor: React.FC = ({ const rows = await api.listWatchableTerminals( draft.rule.id.length > 0 ? draft.rule.id : null, draft.rule.targetIds.length > 0 ? draft.rule.targetIds : null, + writing.targetMode === 'rule' + ? [writing.criterion, writing.excludeCriterion].filter( + (criterion): criterion is NonNullable => criterion != null, + ) + : [], ); setTerminals(rows); setTerminalsError(null); @@ -227,9 +232,17 @@ export const AutomationEditor: React.FC = ({ setTerminalsLoading(false); } // The pick set is a dependency because a newly ticked id has to appear in the roster with - // its snapshot; the rule id is one because it changes exactly once, on the first save. + // its snapshot; the criteria are because they decide whether the roster needs process/cwd + // data; the rule id changes exactly once, on the first save. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [api, draft.rule.id, draft.rule.targetIds.join(',')]); + }, [ + api, + draft.rule.id, + draft.rule.targetIds.join(','), + writing.targetMode, + writing.criterion, + writing.excludeCriterion, + ]); /** * The roster is POLLED, not fetched once. diff --git a/src/renderer/components/Automation/__tests__/automationEditorLifecycle.test.tsx b/src/renderer/components/Automation/__tests__/automationEditorLifecycle.test.tsx index e5113d5..be6374c 100644 --- a/src/renderer/components/Automation/__tests__/automationEditorLifecycle.test.tsx +++ b/src/renderer/components/Automation/__tests__/automationEditorLifecycle.test.tsx @@ -448,6 +448,28 @@ describe('the editor, mounted', () => { } }); + it('asks the roster for the draft exception criterion, not the saved rule alone', async () => { + const api = await openEditorOn(rule({ criterion: 'allTerminals' })); + const exception = editor()!.querySelector( + '[aria-label="What the exception must match"]', + )!; + + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, 'value', + )!.set!; + setter.call(exception, 'commandContains'); + exception.dispatchEvent(new Event('change', { bubbles: true })); + }); + await settle(); + + expect(api.listWatchableTerminals.mock.calls.at(-1)).toEqual([ + 'au-1', + null, + ['allTerminals', 'commandContains'], + ]); + }); + /** * **A save that visibly does nothing, on the most common path into this editor.** * diff --git a/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx b/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx index 28e1c9c..f9f3931 100644 --- a/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx +++ b/src/renderer/components/Automation/__tests__/automationExclusions.test.tsx @@ -124,6 +124,14 @@ describe('the exclusions panel', () => { expect(numbers.map((n) => n.textContent)).toEqual(['4', '2', '2']); }); + it('advises when the shared preview resolves no terminals to watch', async () => { + installPreview({ matched: ['tm-a'], excluded: ['tm-a'], watching: [] }); + await show(rule({ excludedIds: ['tm-a'] })); + + expect(container.querySelector('.au-termcount')?.textContent) + .toBe('Matching 1 - excluded 1 = watching 0 — nothing is being watched'); + }); + it('does not offer exclusions on a hand-picked target set', async () => { await show(rule({ targetMode: 'pinned', diff --git a/src/renderer/components/Automation/panels/MonitorPanel.tsx b/src/renderer/components/Automation/panels/MonitorPanel.tsx index ada5346..a44180a 100644 --- a/src/renderer/components/Automation/panels/MonitorPanel.tsx +++ b/src/renderer/components/Automation/panels/MonitorPanel.tsx @@ -192,6 +192,7 @@ export const MonitorPanel: React.FC = ({ Matching {targetPreview.matched.length} {' - '}excluded {targetPreview.excluded.length} {' = '}watching {targetPreview.watching.length} + {targetPreview.watching.length === 0 && ' — nothing is being watched'} )} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index b01ca8c..a15731c 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -403,6 +403,7 @@ export interface ElectronAPI { listWatchableTerminals?: ( ruleId: string | null, includeIds: string[] | null, + criteria: AutomationCriterion[], ) => Promise; previewAutomationTargets?: ( rule: AutomationRule, From e048846d420d28a357ce76a950888eacfec1c1c2 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 15:19:21 -0500 Subject: [PATCH 07/36] feat(automation): add webhook graph destination --- src-tauri/src/automation/targeting.rs | 5 +- src-tauri/src/automation_engine.rs | 5 +- src-tauri/src/automation_engine/dry.rs | 114 ++++++--------- src-tauri/src/automation_engine/eval.rs | 5 +- src-tauri/src/automation_engine/loops.rs | 48 ++++--- src-tauri/src/automation_engine/test_host.rs | 9 +- src-tauri/src/automation_store.rs | 135 ++++++++++++++---- src-tauri/src/automation_validation.rs | 30 ++-- .../components/Automation/AuInspector.tsx | 2 +- .../components/Automation/AuTestPane.tsx | 4 +- .../__tests__/automationRoundTrip.test.ts | 5 +- .../components/Automation/automationDerive.ts | 23 ++- .../components/Automation/automationDraft.ts | 34 +++-- .../Automation/automationValidation.ts | 13 +- .../Automation/panels/ActionPanel.tsx | 3 +- .../Automations/automationTemplates.ts | 3 +- src/renderer/types/electron.d.ts | 20 ++- 17 files changed, 284 insertions(+), 174 deletions(-) diff --git a/src-tauri/src/automation/targeting.rs b/src-tauri/src/automation/targeting.rs index 285bbb5..02262ed 100644 --- a/src-tauri/src/automation/targeting.rs +++ b/src-tauri/src/automation/targeting.rs @@ -522,13 +522,14 @@ mod tests { keep: Keep::Brackets, }), cond: Some(CondStep { finds: Finds::Reading, op: Some(CompareOp::Gt), threshold: Some(25.0), ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "m".into(), send_to: SendTo::Matched, submit: true, cli_type: "default".into(), substitute: false, - }, + }), + webhook: None, }, created_at: 0, updated_at: 0, diff --git a/src-tauri/src/automation_engine.rs b/src-tauri/src/automation_engine.rs index bc04c63..e7efbbb 100644 --- a/src-tauri/src/automation_engine.rs +++ b/src-tauri/src/automation_engine.rs @@ -867,13 +867,14 @@ mod tests { threshold: Some(25.0), ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "prepare to do context-hand-off".to_string(), send_to: SendTo::Matched, submit: true, cli_type: "default".to_string(), substitute: false, - }, + }), + webhook: None, }, created_at: 1_000, updated_at: 1_000, diff --git a/src-tauri/src/automation_engine/dry.rs b/src-tauri/src/automation_engine/dry.rs index 0d69937..ecbe0be 100644 --- a/src-tauri/src/automation_engine/dry.rs +++ b/src-tauri/src/automation_engine/dry.rs @@ -26,7 +26,7 @@ use crate::automation_engine::schedule::clock_time; use crate::automation_engine::subst; use crate::automation_engine::AutomationEngine; use crate::automation_store::{ - AutomationLogEntry, AutomationRule, Clause, CompareOp, Finds, Join, LogKind, Test, TextOp, + ActionStep, AutomationLogEntry, AutomationRule, Clause, CompareOp, Finds, Join, LogKind, Test, TextOp, TimerMode, TimerStep, }; @@ -126,6 +126,21 @@ fn skipped(kind: &str) -> StepTrace { step(kind, "skipped", "not reached".to_string()) } +fn action_trace(action: Option<&ActionStep>, caps: Option<&eval::Captures>, terminal_name: Option<&str>) -> Option { + let action = action?; + Some(match preview_message(&action.message, action.substitute, caps) { + Ok(body) => step(ACTION, "ok", match terminal_name { + Some(name) => format!("would type `{}` into {}", body, name), + None => format!("would type `{}`", body), + }), + Err(e) => step(ACTION, "failed", format!("nothing would be sent — `{}` had no value here", e)), + }) +} + +fn push_skipped_action(steps: &mut Vec, action: Option<&ActionStep>) { + if action.is_some() { steps.push(skipped(ACTION)); } +} + fn symbol(op: CompareOp) -> &'static str { match op { CompareOp::Gt => ">", @@ -264,7 +279,9 @@ pub fn evaluate_once( // this branch still answers an unsaved draft or a row that predates the guard, so it // stays the honest, non-panicking answer regardless of whether validation would also // have refused it. - return finish(UNREADABLE, vec![skipped(TIMER), skipped(ACTION)]); + let mut steps = vec![skipped(TIMER)]; + push_skipped_action(&mut steps, rule.graph.action.as_ref()); + return finish(UNREADABLE, steps); }; // **I1.** `schedule_due` refuses a `minute_of_day` outside `0..MINUTES_PER_DAY` and a // `days` mask with no weekday bit set — and until this check existed, this branch asked @@ -293,10 +310,7 @@ pub fn evaluate_once( }; return finish( WOULD_NOT_FIRE, - vec![ - step(TIMER, "failed", format!("this schedule can never fire — {why}")), - skipped(ACTION), - ], + { let mut steps = vec![step(TIMER, "failed", format!("this schedule can never fire — {why}"))]; push_skipped_action(&mut steps, rule.graph.action.as_ref()); steps }, ); } let timer_step = step( @@ -309,42 +323,27 @@ pub fn evaluate_once( let Some(_pc) = host.process_for_leaf(tm) else { return finish( UNREADABLE, - vec![timer_step, step(ACTION, "failed", "that terminal is not open right now".to_string())], + { let mut steps = vec![timer_step]; if rule.graph.action.is_some() { steps.push(step(ACTION, "failed", "that terminal is not open right now".to_string())); } steps }, ); }; - let action_step = match preview_message(&rule.graph.action.message, rule.graph.action.substitute, None) { - Ok(body) => step( - ACTION, - "ok", - match &terminal_name { - Some(name) => format!("would type `{}` into {}", body, name), - None => format!("would type `{}`", body), - }, - ), - Err(e) => step(ACTION, "failed", format!("nothing would be sent — `{}` had no value here", e)), - }; - return finish(WOULD_FIRE, vec![timer_step, action_step]); + let mut steps = vec![timer_step]; + if let Some(action_step) = action_trace(rule.graph.action.as_ref(), None, terminal_name.as_deref()) { steps.push(action_step); } + return finish(WOULD_FIRE, steps); } let Some(steps) = eval::InputSteps::of(&rule.graph) else { return finish( UNREADABLE, - vec![skipped(MONITOR), skipped(PARSE), skipped(COND), skipped(ACTION)], + { let mut steps = vec![skipped(MONITOR), skipped(PARSE), skipped(COND)]; push_skipped_action(&mut steps, rule.graph.action.as_ref()); steps }, ); }; let pattern = steps.parse.find.clone(); // 1. Monitor. A terminal that is not live is not readable, and that is the whole answer. let Some(pc) = host.process_for_leaf(tm) else { - return finish( - UNREADABLE, - vec![ - step(MONITOR, "failed", "that terminal is not open right now".to_string()), - skipped(PARSE), - skipped(COND), - skipped(ACTION), - ], - ); + let mut steps = vec![step(MONITOR, "failed", "that terminal is not open right now".to_string()), skipped(PARSE), skipped(COND)]; + push_skipped_action(&mut steps, rule.graph.action.as_ref()); + return finish(UNREADABLE, steps); }; // 2. The pattern has to compile before anything can be read for it. The editor's validation says @@ -352,9 +351,7 @@ pub fn evaluate_once( let re = match crate::automation_validation::compile(&pattern) { Ok(re) => re, Err(e) => { - return finish( - WOULD_NOT_FIRE, - vec![ + let mut steps = vec![ step(MONITOR, "ok", "the terminal is open".to_string()), step( PARSE, @@ -365,9 +362,9 @@ pub fn evaluate_once( ), ), skipped(COND), - skipped(ACTION), - ], - ); + ]; + push_skipped_action(&mut steps, rule.graph.action.as_ref()); + return finish(WOULD_NOT_FIRE, steps); } }; @@ -398,15 +395,9 @@ pub fn evaluate_once( let echoes = engine.runtime.echoes_for(tm, now_ms); let port = HostPort(host); let Some(ev) = eval::evaluate(folded, &re, &echoes, prev, &port, &pc, now_ms) else { - return finish( - UNREADABLE, - vec![ - step(MONITOR, "failed", "there is nothing to read from that terminal yet".to_string()), - skipped(PARSE), - skipped(COND), - skipped(ACTION), - ], - ); + let mut steps = vec![step(MONITOR, "failed", "there is nothing to read from that terminal yet".to_string()), skipped(PARSE), skipped(COND)]; + push_skipped_action(&mut steps, rule.graph.action.as_ref()); + return finish(UNREADABLE, steps); }; let monitor = step(MONITOR, "ok", format!("read {}", eval::depth_words(ev.depth))); @@ -518,30 +509,9 @@ pub fn evaluate_once( let would_fire = ev.condition == Truth::True; let action = if would_fire { - match preview_message( - &rule.graph.action.message, - rule.graph.action.substitute, - ev.captures.as_ref(), - ) { - Ok(body) => step( - ACTION, - "ok", - match &terminal_name { - Some(name) => format!("would type `{}` into {}", body, name), - None => format!("would type `{}`", body), - }, - ), - // §4.4: the same refusal `run_send` would make, reported rather than typed. The - // verdict below still answers the CONDITION (it matched), and this row alone carries - // that the send itself would be refused. - Err(e) => step( - ACTION, - "failed", - format!("nothing would be sent — `{}` had no value here", e), - ), - } + action_trace(rule.graph.action.as_ref(), ev.captures.as_ref(), terminal_name.as_deref()) } else { - step(ACTION, "skipped", "nothing would be sent".to_string()) + rule.graph.action.as_ref().map(|_| step(ACTION, "skipped", "nothing would be sent".to_string())) }; // A DELAY rule (`AfterMatch`) inserts a `timer` row here, between the comparison and the send — @@ -565,7 +535,7 @@ pub fn evaluate_once( if let Some(t) = timer_step { all_steps.push(t); } - all_steps.push(action); + if let Some(action) = action { all_steps.push(action); } finish(verdict, all_steps) } @@ -1151,8 +1121,8 @@ mod tests { let mut rule = ctx_rule("au-1"); rule.graph.parse_mut().find = r"FAILED (\d+) tests in (\S+)".into(); rule.graph.cond = Some(CondStep { finds: Finds::Event, ..Default::default() }); - rule.graph.action.message = "Fix the $1 failing tests in $2".into(); - rule.graph.action.substitute = true; + rule.graph.action_mut().message = "Fix the $1 failing tests in $2".into(); + rule.graph.action_mut().substitute = true; fake.say("pc-1", "FAILED 17 tests in a.ts"); let report = evaluate_once(&engine, host.as_ref(), &rule, "tm-1", 1_000); @@ -1174,8 +1144,8 @@ mod tests { let mut rule = ctx_rule("au-1"); rule.graph.parse_mut().find = r"FAILED (\d+)".into(); rule.graph.cond = Some(CondStep { finds: Finds::Event, ..Default::default() }); - rule.graph.action.message = "Fix $3".into(); - rule.graph.action.substitute = true; + rule.graph.action_mut().message = "Fix $3".into(); + rule.graph.action_mut().substitute = true; fake.say("pc-1", "FAILED 17"); let report = evaluate_once(&engine, host.as_ref(), &rule, "tm-1", 1_000); diff --git a/src-tauri/src/automation_engine/eval.rs b/src-tauri/src/automation_engine/eval.rs index 8d88d97..5e63dbd 100644 --- a/src-tauri/src/automation_engine/eval.rs +++ b/src-tauri/src/automation_engine/eval.rs @@ -876,13 +876,14 @@ mod tests { keep: Keep::Brackets, }), cond: Some(CondStep { finds, op, threshold, ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "prepare to do context-hand-off".to_string(), send_to: SendTo::Matched, submit: true, cli_type: "default".to_string(), substitute: false, - }, + }), + webhook: None, } } diff --git a/src-tauri/src/automation_engine/loops.rs b/src-tauri/src/automation_engine/loops.rs index 839bb27..c819fe0 100644 --- a/src-tauri/src/automation_engine/loops.rs +++ b/src-tauri/src/automation_engine/loops.rs @@ -497,6 +497,11 @@ pub async fn evaluate_tick( // sends in one tick would freeze evaluation for two seconds. Serialisation is unaffected: it was // never the tick that provided it, it was the per-terminal lock. for send in sends { + // A webhook-only rule has no terminal destination. Never dispatch `run_send` for it: an + // invented empty action can submit a bare Enter to a live terminal. + if send.pair.rule.rule.graph.action.is_none() { + continue; + } let engine = engine.clone(); let host = host.clone(); tokio::spawn(async move { run_send(engine, host, send).await }); @@ -741,6 +746,10 @@ pub async fn run_send( ) { let rule = &send.pair.rule.rule; let tm = send.pair.tm.clone(); + // Belt and braces for future callers that bypass the dispatch guard. + let Some(action) = rule.graph.action.as_ref() else { + return; + }; // **Before the queue.** §2.6 layer 2 runs for `ECHO_SETTLE_MS` after the WRITE, and the wait for // this terminal's lock is up to `SEND_QUEUE_TIMEOUT_MS` of the distance between the decision and // that write. Started after the lock, this measured only `deliver` — so the second and later @@ -810,7 +819,6 @@ pub async fn run_send( return; } - let action = &rule.graph.action; let body = if action.substitute { match subst::substitute(&action.message, send.captures.as_ref()) { Ok(s) => s, @@ -1319,8 +1327,8 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = r"FAILED (\d+) tests in (\S+)".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "Fix the $1 failing tests in $2".into(); - g.action.substitute = true; + g.action_mut().message = "Fix the $1 failing tests in $2".into(); + g.action_mut().substitute = true; }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -1403,7 +1411,7 @@ mod tests { g.parse_mut().find = "FAILED".into(); g.parse_mut().keep = Keep::Whole; g.cond_mut().finds = Finds::Event; - g.action.message = "stand-up notes?".into(); + g.action_mut().message = "stand-up notes?".into(); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2092,8 +2100,8 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = r"FAILED (\d+)".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "awk '{print $1}'".into(); - g.action.substitute = false; + g.action_mut().message = "awk '{print $1}'".into(); + g.action_mut().substitute = false; }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2123,8 +2131,8 @@ mod tests { let (engine, fake, host) = rig_with_rule_bypassing_the_enable_gate(|g| { g.parse_mut().find = r"FAILED (\d+)".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "Fix $3".into(); - g.action.substitute = true; + g.action_mut().message = "Fix $3".into(); + g.action_mut().substitute = true; }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2162,7 +2170,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2204,7 +2212,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2264,7 +2272,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2313,7 +2321,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 300_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2364,7 +2372,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 300_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2409,7 +2417,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2459,8 +2467,8 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = r"API error (\d+)".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume after $1".into(); - g.action.substitute = true; + g.action_mut().message = "resume after $1".into(); + g.action_mut().substitute = true; g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2505,7 +2513,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2567,7 +2575,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2599,7 +2607,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); @@ -2630,7 +2638,7 @@ mod tests { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; - g.action.message = "resume".into(); + g.action_mut().message = "resume".into(); g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); diff --git a/src-tauri/src/automation_engine/test_host.rs b/src-tauri/src/automation_engine/test_host.rs index fadebeb..66e60f1 100644 --- a/src-tauri/src/automation_engine/test_host.rs +++ b/src-tauri/src/automation_engine/test_host.rs @@ -248,13 +248,14 @@ pub(crate) fn ctx_rule(id: &str) -> AutomationRule { keep: Keep::Brackets, }), cond: Some(CondStep { finds: Finds::Reading, op: Some(CompareOp::Gt), threshold: Some(25.0), ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "prepare to do context-hand-off".into(), send_to: SendTo::Matched, submit: true, cli_type: "claude".into(), substitute: false, - }, + }), + webhook: None, }, created_at: 1_000, updated_at: 1_000, @@ -338,7 +339,7 @@ pub(crate) fn log_kinds(store: &AutomationStore) -> Vec { /// the write log by their contents rather than by the order they happen to arrive in. pub(crate) fn ctx_rule_saying(id: &str, message: &str, sort_order: i64) -> AutomationRule { let mut rule = ctx_rule(id); - rule.graph.action.message = message.into(); + rule.graph.action_mut().message = message.into(); rule.sort_order = sort_order; rule } @@ -359,7 +360,7 @@ pub(crate) fn schedule_only_rule(id: &str) -> AutomationRule { rule.graph.timer = Some(TimerStep { mode: TimerMode::DailyAt { minute_of_day: 9 * 60, days: 0b0001_1111 }, }); - rule.graph.action.message = "stand-up notes?".into(); + rule.graph.action_mut().message = "stand-up notes?".into(); rule } diff --git a/src-tauri/src/automation_store.rs b/src-tauri/src/automation_store.rs index d98d246..e9b2388 100644 --- a/src-tauri/src/automation_store.rs +++ b/src-tauri/src/automation_store.rs @@ -28,9 +28,9 @@ use rusqlite::Connection; /// silently reinterpreting a graph we do not understand is how a rule starts typing the wrong thing /// into a terminal. `reload` logs exactly one entry per skipped rule per load. Plan §7.3. /// -/// `2` as of plan 032 §3.2 (task 27): a rule is stamped `2` only when [`schema_version_for`] finds it -/// actually uses a v2 feature, never merely because this build can write one. -pub const SUPPORTED_SCHEMA_VERSION: i64 = 2; +/// `3` as of the webhook milestone: a rule is stamped only for the newest feature it actually uses, +/// never merely because this build can write one. +pub const SUPPORTED_SCHEMA_VERSION: i64 = 3; // --------------------------------------------------------------------------------------------- // The DTO. These serde names are THE AUTHORITY for the whole feature (plan §7.7): the renderer's @@ -358,6 +358,29 @@ pub struct ActionStep { pub substitute: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WebhookProvider { Discord, Teams, Slack, Custom } + +/// An optional destination outside the terminal. Its URL is persisted and sent over IPC in the +/// clear by design, but is never displayed, logged, exported, or put in an error. +#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookStep { + pub provider: WebhookProvider, + pub url: String, + pub body: String, + #[serde(default)] + pub substitute: bool, +} + +impl std::fmt::Debug for WebhookStep { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebhookStep").field("provider", &self.provider).field("url", &"") + .field("body", &self.body).field("substitute", &self.substitute).finish() + } +} + fn default_send_to() -> SendTo { SendTo::Matched } @@ -468,7 +491,10 @@ pub struct AutomationGraph { /// shape and this attribute becomes load-bearing rather than decorative. #[serde(default)] pub timer: Option, - pub action: ActionStep, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook: Option, /// Where the editor's four cards sit on its canvas. /// /// **View state, deliberately inside the rule's blob.** The plan originally kept the layout out @@ -503,14 +529,18 @@ pub struct AutomationGraph { /// opposite (monotonic, "once v2 always v2") is the more obvious thing to write by accident, which /// is why `schema_version_is_stamped_from_what_the_rule_actually_uses` pins the non-sticky case /// explicitly rather than leaving it to be implied by the others (task 27 ruling R4). -pub fn schema_version_for(graph: &AutomationGraph) -> i64 { +pub fn schema_version_for(rule: &AutomationRule) -> i64 { + let graph = &rule.graph; let uses_a_v2_feature = graph.timer.is_some() || graph.monitor.is_none() || graph.parse.is_none() || graph.cond.is_none() || graph.cond.as_ref().is_some_and(|c| !c.clauses.is_empty()) - || graph.action.substitute; - if uses_a_v2_feature { + || graph.action.as_ref().is_some_and(|action| action.substitute); + let uses_a_v3_feature = graph.webhook.is_some() || graph.action.is_none() + || !rule.excluded_ids.is_empty() || rule.exclude_criterion.is_some() + || !rule.exclude_criterion_value.is_empty(); + if uses_a_v3_feature { 3 } else if uses_a_v2_feature { 2 } else { 1 @@ -542,6 +572,10 @@ impl AutomationGraph { self.cond.as_mut().expect("this fixture's rule has a cond step") } #[track_caller] + pub fn action_mut(&mut self) -> &mut ActionStep { + self.action.as_mut().expect("this fixture's rule has an action step") + } + #[track_caller] pub fn monitor_ref(&self) -> &MonitorStep { self.monitor.as_ref().expect("this fixture's rule has a monitor step") } @@ -1653,7 +1687,7 @@ impl AutomationStore { let criterion = enum_to_db(&rule.criterion)?; let exclude_criterion = rule.exclude_criterion.as_ref().map(enum_to_db).transpose()?; let schema_version = if rule.schema_version <= SUPPORTED_SCHEMA_VERSION { - schema_version_for(&rule.graph) + schema_version_for(rule) } else { rule.schema_version }; @@ -2376,13 +2410,14 @@ mod tests { threshold: Some(25.0), ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "prepare to do context-hand-off".to_string(), send_to: SendTo::Matched, submit: true, cli_type: "claude".to_string(), substitute: false, - }, + }), + webhook: None, } } @@ -2719,38 +2754,66 @@ mod tests { fn graph_with_substitute() -> AutomationGraph { let mut g = graph(); - g.action.substitute = true; + g.action_mut().substitute = true; g } + fn stamped(graph: AutomationGraph) -> i64 { + let mut rule = rule("au-stamp"); + rule.graph = graph; + schema_version_for(&rule) + } + /// One row per term of the predicate, plus the plain rule that must STAY v1. A single row would /// leave the other six silently wrong — `schema_version_for` folds seven conditions into one /// bool, and a table is the only shape that shows a wrong term rather than just a wrong result. #[test] fn schema_version_is_stamped_from_what_the_rule_actually_uses() { assert_eq!( - schema_version_for(&graph()), + stamped(graph()), 1, "a plain four-step rule must still load on an older build" ); - assert_eq!(schema_version_for(&graph_with_timer()), 2); - assert_eq!(schema_version_for(&graph_with_no_monitor()), 2); - assert_eq!(schema_version_for(&graph_with_no_parse()), 2); - assert_eq!(schema_version_for(&graph_with_no_cond()), 2); - assert_eq!(schema_version_for(&graph_with_one_clause()), 2); - assert_eq!(schema_version_for(&graph_with_substitute()), 2); + assert_eq!(stamped(graph_with_timer()), 2); + assert_eq!(stamped(graph_with_no_monitor()), 2); + assert_eq!(stamped(graph_with_no_parse()), 2); + assert_eq!(stamped(graph_with_no_cond()), 2); + assert_eq!(stamped(graph_with_one_clause()), 2); + assert_eq!(stamped(graph_with_substitute()), 2); + let mut webhook = graph(); + webhook.webhook = Some(WebhookStep { provider: WebhookProvider::Discord, url: "https://example.invalid/hook".into(), body: "build failed".into(), substitute: false }); + assert_eq!(stamped(webhook.clone()), 3); + webhook.webhook = None; + assert_eq!(stamped(webhook), 1, "removing the last webhook drops the stamp back"); + let mut no_action = graph(); + no_action.action = None; + assert_eq!(stamped(no_action), 3); // R4: not sticky. Dropping the last clause must not leave the rule permanently v2 — the // opposite (monotonic) behaviour is the more obvious thing to write by accident. let mut g = graph_with_one_clause(); g.cond.as_mut().unwrap().clauses.clear(); assert_eq!( - schema_version_for(&g), + stamped(g), 1, "dropping the last clause makes the rule v1-compatible again" ); } + #[test] + fn a_rule_with_exclusions_is_stamped_v3_even_on_a_v1_graph() { + let mut excluded = rule("au-excluded"); + excluded.schema_version = 1; + excluded.excluded_ids = vec!["tm-secret".into()]; + assert_eq!(schema_version_for(&excluded), 3); + excluded.excluded_ids.clear(); + assert_eq!(schema_version_for(&excluded), 1); + excluded.exclude_criterion_value = "scratch".into(); + assert_eq!(schema_version_for(&excluded), 3); + excluded.exclude_criterion_value.clear(); + assert_eq!(schema_version_for(&excluded), 1); + } + /// §3.2's whole point: merely loading and re-saving an old rule must not brick it for a /// downgrade. The in-memory fold (§5.4, `fold_v1_clauses`) gives a v1 numeric rule one clause /// at LOAD, in the engine, on a copy that is never written back — so the row this test reads @@ -2789,7 +2852,26 @@ mod tests { }"#; let decoded: AutomationGraph = serde_json::from_str(raw).expect("a graph missing only a newer optional field must still decode"); - assert!(!decoded.action.substitute, "a rule from before this field existed must load with it off"); + assert!(!decoded.action.as_ref().unwrap().substitute, "a rule from before this field existed must load with it off"); + } + + #[test] + fn a_webhook_only_rule_writes_no_action_key() { + let mut graph = graph(); + graph.action = None; + graph.webhook = Some(WebhookStep { provider: WebhookProvider::Slack, url: "https://example.invalid/hook".into(), body: "build failed".into(), substitute: false }); + assert!(!serde_json::to_string(&graph).unwrap().contains("\"action\"")); + } + + #[test] + fn a_graph_with_no_webhook_writes_no_webhook_key() { + assert!(!serde_json::to_string(&graph()).unwrap().contains("\"webhook\"")); + } + + #[test] + fn a_v1_rule_still_round_trips_byte_for_byte() { + let v1 = r#"{"kind":"number","op":"gt","threshold":25.0}"#; + assert_eq!(serde_json::to_string(&serde_json::from_str::(v1).unwrap()).unwrap(), v1); } // -- §10.14b ------------------------------------------------------------------------------ @@ -3108,7 +3190,7 @@ mod tests { let store = AutomationStore::new_in_memory(); let mut bad = rule("au-1"); bad.enabled = true; - bad.graph.action.message = String::new(); + bad.graph.action_mut().message = String::new(); assert!(store.save_rule_as_of(&bad, 1_000).is_err(), "an enabled rule with no message"); assert!(store.get_rule("au-1").unwrap().is_none(), "and nothing was written"); @@ -3574,7 +3656,7 @@ mod tests { let mut rule = enableable("au-bad"); rule.enabled = false; rule.target_ids.clear(); - rule.graph.action.message = String::new(); + rule.graph.action_mut().message = String::new(); store.save_rule(&rule).unwrap(); let refused = store.set_enabled_checked("au-bad", true); @@ -3615,7 +3697,7 @@ mod tests { let mut empty = enableable("au-empty"); empty.enabled = false; empty.target_ids.clear(); - empty.graph.action.message = String::new(); + empty.graph.action_mut().message = String::new(); store.save_rule(&empty).unwrap(); store.set_enabled_checked("au-empty", false).unwrap(); @@ -3636,7 +3718,7 @@ mod tests { let mut broken = enableable("au-1"); broken.enabled = true; - broken.graph.action.message = String::new(); + broken.graph.action_mut().message = String::new(); let refused = store.save_rule(&broken); assert!(matches!(refused, Err(AutomationStoreError::Invalid(_))), "{:?}", refused); assert!(store.get_rule("au-1").unwrap().is_none(), "refused, and yet the row was written"); @@ -4479,13 +4561,14 @@ mod tests { timer: Some(TimerStep { mode: TimerMode::DailyAt { minute_of_day: 9 * 60, days: 0b0001_1111 }, }), - action: ActionStep { + action: Some(ActionStep { message: "stand-up notes?".to_string(), send_to: SendTo::Matched, submit: true, cli_type: "default".to_string(), substitute: false, - }, + }), + webhook: None, layout: None, }; diff --git a/src-tauri/src/automation_validation.rs b/src-tauri/src/automation_validation.rs index 2c999c3..3371490 100644 --- a/src-tauri/src/automation_validation.rs +++ b/src-tauri/src/automation_validation.rs @@ -316,7 +316,8 @@ pub const MAX_DELAY_MS: i64 = 10 * 60 * 1_000; fn never_runs_problem(graph: &AutomationGraph) -> Option { let has_input_steps = graph.monitor.is_some() && graph.parse.is_some() && graph.cond.is_some(); let scheduled = matches!(graph.timer, Some(TimerStep { mode: TimerMode::DailyAt { .. } })); - if has_input_steps || scheduled || graph.action.message.trim().is_empty() { + let has_terminal_destination = graph.action.as_ref().is_some_and(|action| !action.message.trim().is_empty()); + if has_input_steps || scheduled || graph.webhook.is_some() || !has_terminal_destination { return None; } let message = if graph.timer.is_some() { @@ -642,14 +643,16 @@ pub fn problems(rule: &AutomationRule) -> Vec { out.extend(timer_problems(&rule.graph)); // --- message -------------------------------------------------------------------------------- - if rule.graph.action.message.trim().is_empty() { + if (rule.graph.action.is_none() && rule.graph.webhook.is_none()) + || rule.graph.action.as_ref().is_some_and(|action| action.message.trim().is_empty()) + { out.push(Problem::new( Severity::Blocks, "action", "action.empty", "Enter the message this rule should type.", )); - } else if let Some(parse) = parse_step(&rule.graph) { + } else if let (Some(action), Some(parse)) = (rule.graph.action.as_ref(), parse_step(&rule.graph)) { // §2.6's failure, told to the user before it happens: a rule whose own message matches its // own pattern reads its own echo. The needle guard handles it, which is why this WARNS — // but the guard has a TTL and a cap, and a user who can see the collision can avoid it. @@ -667,7 +670,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { // message `HANDOFF now` — was warned about as an echo of itself. Both mirrors had the same // bug, so the shared fixture agreed with itself and could not see it. if let Ok(re) = compile(&parse.find) { - if re.is_match(&rule.graph.action.message) { + if re.is_match(&action.message) { out.push(Problem::new( Severity::Warns, "action", @@ -692,7 +695,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { // toggle claims the message inserts a capture, and a rule with no parse step at all captures // nothing, exactly like one whose pattern is still empty. `parse_step` is what makes the two // spellings indistinguishable to this check. - if rule.graph.action.substitute { + if let Some(action) = rule.graph.action.as_ref().filter(|action| action.substitute) { match parse_step(&rule.graph) { // The toggle itself claims the message inserts a capture, which nothing can be true // of before a pattern exists — asked regardless of whether a token has actually been @@ -707,7 +710,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { Some(parse) => { if let Ok(compiled) = compile(&parse.find) { let count = compiled.captures_len().saturating_sub(1); - for token in subst::tokens_used(&rule.graph.action.message) { + for token in subst::tokens_used(&action.message) { let bad = match &token { subst::Token::Whole => false, subst::Token::Group(n) => !token_supplied(&compiled, Some(*n), None), @@ -768,13 +771,14 @@ mod tests { monitor: Some(MonitorStep { read: ReadMode::NewOutput, cadence: Cadence::OnOutput, every_ms: 0 }), parse: Some(ParseStep { preset: ParsePreset::Custom, literal: None, find: find.into(), keep }), cond: Some(CondStep { finds: Finds::Reading, op: Some(CompareOp::Gt), threshold: Some(25.0), ..Default::default() }), - action: ActionStep { + action: Some(ActionStep { message: "m".into(), send_to: SendTo::Matched, submit: true, cli_type: "default".into(), substitute: false, - }, + }), + webhook: None, } } @@ -957,7 +961,7 @@ mod tests { ), ( "nothing to type", - Box::new(|r: &mut AutomationRule| r.graph.action.message = " ".into()), + Box::new(|r: &mut AutomationRule| r.graph.action_mut().message = " ".into()), "action", ), ]; @@ -1005,7 +1009,7 @@ mod tests { let mut rule = valid_rule(); rule.graph.parse_mut().find = "HANDOFF".into(); rule.graph.cond = Some(CondStep { finds: Finds::Event, ..Default::default() }); - rule.graph.action.message = "HANDOFF now".into(); + rule.graph.action_mut().message = "HANDOFF now".into(); let found = problems(&rule); assert_eq!(found.len(), 1, "{:?}", found); @@ -1025,7 +1029,7 @@ mod tests { fn blocking_problems_come_before_warnings() { let mut rule = valid_rule(); rule.graph.parse_mut().find = r"ctx:(\d+)(%)".into(); - rule.graph.action.message = String::new(); + rule.graph.action_mut().message = String::new(); let found = problems(&rule); assert_eq!(found.len(), 2, "{:?}", found); @@ -1272,7 +1276,7 @@ mod tests { rule.graph.parse = None; rule.graph.cond = None; rule.graph.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); - rule.graph.action.message = "resume".into(); + rule.graph.action_mut().message = "resume".into(); let found = problems(&rule); assert_eq!( @@ -1476,7 +1480,7 @@ mod tests { updated_at: 0, }; rule.graph.cond = Some(CondStep { finds: Finds::Event, ..Default::default() }); - rule.graph.action.message = "anything at all".into(); + rule.graph.action_mut().message = "anything at all".into(); let found = problems(&rule); assert_eq!(found.len(), 1, "only the empty pattern, no echo warning: {found:?}"); diff --git a/src/renderer/components/Automation/AuInspector.tsx b/src/renderer/components/Automation/AuInspector.tsx index 9fb2fff..493ce1a 100644 --- a/src/renderer/components/Automation/AuInspector.tsx +++ b/src/renderer/components/Automation/AuInspector.tsx @@ -133,7 +133,7 @@ export const AuInspector: React.FC = (props) => { {step === 'timer' && ( )} - {step === 'action' && ( + {step === 'action' && draft.rule.graph.action && ( )} diff --git a/src/renderer/components/Automation/AuTestPane.tsx b/src/renderer/components/Automation/AuTestPane.tsx index 8a4681c..4ada8c7 100644 --- a/src/renderer/components/Automation/AuTestPane.tsx +++ b/src/renderer/components/Automation/AuTestPane.tsx @@ -39,8 +39,8 @@ const VERDICTS: Record function pillFor(report: DryRunReport): { label: string; tone: string } { const base = VERDICTS[report.verdict]; if (report.verdict !== 'would-fire') return base; - const actionFailed = report.steps.find((s) => s.kind === 'action')?.status === 'failed'; - if (!actionFailed) return base; + const destinationFailed = report.steps.find((s) => s.kind === 'action' || s.kind === 'webhook')?.status === 'failed'; + if (!destinationFailed) return base; return { label: 'Would fire — but nothing would be sent', tone: base.tone }; } diff --git a/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts b/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts index 42da1a3..d8acea4 100644 --- a/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts +++ b/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts @@ -495,7 +495,10 @@ describe('draftFromRule', () => { ...blankDraft(), targetMode: 'pinned', targetIds: [], - graph: { ...blankDraft().graph, layout: DEFAULT_LAYOUT }, + graph: (() => { + const { action: _action, ...graph } = blankDraft().graph; + return { ...graph, layout: DEFAULT_LAYOUT }; + })(), }); }); diff --git a/src/renderer/components/Automation/automationDerive.ts b/src/renderer/components/Automation/automationDerive.ts index 1569753..448adc0 100644 --- a/src/renderer/components/Automation/automationDerive.ts +++ b/src/renderer/components/Automation/automationDerive.ts @@ -346,7 +346,13 @@ export function stepValues(rule: AutomationRule, step: StepKind): Record s === 'action' || rule.graph[s] != null); + : STEP_ORDER.filter((s) => rule.graph[s] != null); const layout = layoutOf(rule); // **The rule and the baseline are the SAME object, layout already resolved.** A rule saved // before this field existed has no `graph.layout`, so the arrangement it opens with is the @@ -440,11 +436,17 @@ function graphAsWritten( graph: AutomationRule['graph'], present: readonly StepKind[], ): AutomationRule['graph'] { - if (INPUT_STEPS.some((s) => present.includes(s))) return graph; - // The KEYS go, not `undefined` values: §3.1's own note says the backend omits an absent step - // rather than sending `null`, so an absent step must not decode as a present-but-empty one. - const { monitor: _m, parse: _p, cond: _c, ...rest } = graph; - return rest; + const keepInput = INPUT_STEPS.some((s) => present.includes(s)); + const keepAction = present.includes('action'); + if (keepInput && keepAction) return graph; + // The KEYS go, not `undefined` values. In particular, an action scaffold hidden by the canvas + // must not survive serialisation and submit an Enter in a supposedly webhook-only rule. + const { monitor, parse, cond, action, ...rest } = graph; + return { + ...rest, + ...(keepInput ? { monitor, parse, cond } : {}), + ...(keepAction ? { action } : {}), + }; } /** @@ -645,7 +647,7 @@ export type DraftAction = * expressible-but-refused is the shape that saves clean and comes back broken. */ | { type: 'timer'; mode: AutomationTimerMode } - | { type: 'action'; patch: Partial } + | { type: 'action'; patch: Partial> } | { type: 'select'; step: StepKind | null } | { type: 'addStep'; step: StepKind } | { type: 'moveStep'; step: StepKind; pos: NodePos } @@ -725,7 +727,11 @@ function materialise(rule: AutomationRule, step: StepKind): AutomationRule { ? { ...rule, graph: { ...rule.graph, timer: { mode: DEFAULT_TIMER_MODE } } } : rule; } - if (step === 'action' || INPUT_STEPS.every((s) => rule.graph[s] != null)) return rule; + if (step === 'action') { + const action = rule.graph.action ?? blankDraft().graph.action; + return action ? { ...rule, graph: { ...rule.graph, action } } : rule; + } + if (INPUT_STEPS.every((s) => rule.graph[s] != null)) return rule; const blank = blankDraft().graph; return { ...rule, @@ -836,7 +842,9 @@ export function draftReducer(draft: AutomationDraft, action: DraftAction): Autom return { ...draft, rule: next, wires: defaultWires(draft.present, timerShapeOf(next)) }; } case 'action': - return withGraph(draft, { action: { ...rule.graph.action, ...action.patch } }); + return rule.graph.action + ? withGraph(draft, { action: { ...rule.graph.action, ...action.patch } }) + : draft; case 'select': return { ...draft, selected: action.step }; case 'addStep': { diff --git a/src/renderer/components/Automation/automationValidation.ts b/src/renderer/components/Automation/automationValidation.ts index 3d692fb..f029530 100644 --- a/src/renderer/components/Automation/automationValidation.ts +++ b/src/renderer/components/Automation/automationValidation.ts @@ -429,9 +429,10 @@ export const MINUTES_PER_DAY = 24 * 60; function neverRunsProblem(graph: AutomationGraph): Problem | null { const hasInputSteps = Boolean(graph.monitor) && Boolean(graph.parse) && Boolean(graph.cond); const scheduled = Boolean(graph.timer && 'dailyAt' in graph.timer.mode); - // A blank message defers to `action.empty` alone — see the Rust mirror's doc for why (a brand - // new draft with nothing drawn is not a claim about an undrawn Wait card). - if (hasInputSteps || scheduled || graph.action.message.trim().length === 0) return null; + // A blank terminal message defers to `action.empty` alone; an absent terminal destination is + // valid when a webhook is present, and must not be made into an action to satisfy this guard. + const hasTerminalDestination = (graph.action?.message.trim().length ?? 0) !== 0; + if (hasInputSteps || scheduled || graph.webhook || !hasTerminalDestination) return null; const message = graph.timer ? 'This rule waits, but nothing will ever start the wait: it has no Watch output step to ' + 'match against. Add one, or switch this Wait to run at a time of day instead.' @@ -718,7 +719,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push(...timerProblems(rule.graph)); // --- message --------------------------------------------------------------------------------- - if (action.message.trim().length === 0) { + if ((!action && !rule.graph.webhook) || action?.message.trim().length === 0) { out.push( problem( 'blocks', @@ -727,7 +728,7 @@ export function problems(rule: AutomationRule): Problem[] { 'Enter the message this rule should type.', ), ); - } else if (parse && parse.find.trim().length > 0) { + } else if (action && parse && parse.find.trim().length > 0) { // §2.6's failure, told to the user before it happens. The emptiness guard above is // load-bearing: an empty regex matches every position of every string, so without it every // draft with a message and no pattern yet is told its message matches a pattern it does not @@ -764,7 +765,7 @@ export function problems(rule: AutomationRule): Problem[] { // toggle claims the message inserts a capture, and a rule with no parse step at all captures // nothing, exactly like one whose pattern is still empty. `parseStep` is what makes the two // spellings indistinguishable to this check. - if (action.substitute) { + if (action?.substitute) { const sourcing = parseStep(rule.graph); if (!sourcing) { // The toggle itself claims the message inserts a capture, which nothing can be true of diff --git a/src/renderer/components/Automation/panels/ActionPanel.tsx b/src/renderer/components/Automation/panels/ActionPanel.tsx index 8bfe7bc..86d0d23 100644 --- a/src/renderer/components/Automation/panels/ActionPanel.tsx +++ b/src/renderer/components/Automation/panels/ActionPanel.tsx @@ -80,6 +80,7 @@ export function sampleFromPattern(find: string, keep: AutomationKeep): Record = ({ draft, model, dispatch, sample }) => { const { parse, action } = draft.rule.graph; const messageRef = React.useRef(null); + if (!action) return null; // **This panel survives an absent parse step, and must**: `action` is the one step every rule // has (plan 032 §3.1), so a schedule rule reaches here with no pattern behind it. No pattern is @@ -137,7 +138,7 @@ export const ActionPanel: React.FC = ({ draft, model, dispatch function insertToken(token: string) { const el = messageRef.current; - const value = action.message; + const value = action?.message ?? ''; const start = el?.selectionStart ?? value.length; const end = el?.selectionEnd ?? value.length; const next = value.slice(0, start) + token + value.slice(end); diff --git a/src/renderer/components/Settings/Automations/automationTemplates.ts b/src/renderer/components/Settings/Automations/automationTemplates.ts index bfb6a1e..88e7662 100644 --- a/src/renderer/components/Settings/Automations/automationTemplates.ts +++ b/src/renderer/components/Settings/Automations/automationTemplates.ts @@ -330,7 +330,8 @@ function structuredCloneRule(rule: TemplateRule): TemplateRule { if (graph.parse) graph.parse = deep(graph.parse); if (graph.cond) graph.cond = deep(graph.cond); if (graph.timer) graph.timer = deep(graph.timer); - graph.action = deep(graph.action); + if (graph.action) graph.action = deep(graph.action); + if (graph.webhook) graph.webhook = deep(graph.webhook); if (graph.layout) graph.layout = deep(graph.layout); return { ...rule, targetIds: [...rule.targetIds], graph }; } diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index a15731c..0ce0ca4 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -612,6 +612,17 @@ export interface AutomationActionStep { substitute?: boolean; } +export type AutomationWebhookProvider = 'discord' | 'teams' | 'slack' | 'custom'; + +export interface AutomationWebhookStep { + provider: AutomationWebhookProvider; + /** Secret credential: carried over persistence and IPC, never rendered, logged, errored, or exported. */ + url: string; + /** Plain message for preset providers; raw request body for `custom`. */ + body: string; + substitute?: boolean; +} + /** * Optional fifth step — "Wait" in every user-facing string. **Never write the bare word "Timer" in * UI copy** — `AutomationCadence`'s `'timer'` already means the monitor's poll interval, a @@ -650,7 +661,10 @@ export interface AutomationGraph { cond?: AutomationCondStep; /** Absent on every rule saved before this milestone and on every rule that does not use it. */ timer?: AutomationTimerStep; - action: AutomationActionStep; + /** Optional terminal destination. Absence must stay absence: an empty action can submit Enter. */ + action?: AutomationActionStep; + /** Optional webhook destination. Its URL is a secret and must never be used as display text. */ + webhook?: AutomationWebhookStep; /** * Where the editor's four cards sit on its canvas. View state, and the engine never reads it — * it rides in the rule so that ONE save writes the whole document. Absent on any rule written @@ -724,7 +738,7 @@ export interface AutomationLogEntry { */ export interface DryRunStep { /** - * `monitor` | `parse` | `cond` | `timer` | `action` — **not always all five, and not one fixed + * `monitor` | `parse` | `cond` | `timer` | `action` | `webhook` — **not always all five, and not one fixed * count.** A plain rule (no wait step) reports the original four, in the graph's order. A DELAY * rule (`timer.mode.afterMatch`) inserts `timer` between `cond` and `action`, five steps. A * SCHEDULE rule (`timer.mode.dailyAt`) has no `monitor`, `parse` or `cond` at all — it reads @@ -734,7 +748,7 @@ export interface DryRunStep { * graph's order": that was already only true for a rule with no wait step, and plan 032 §6–§7 is * what made it stop being true for the other two shapes. */ - kind: 'monitor' | 'parse' | 'cond' | 'timer' | 'action'; + kind: 'monitor' | 'parse' | 'cond' | 'timer' | 'action' | 'webhook'; /** * `skipped` is a step that never ran because an earlier one failed. It is not a pass and must * not be drawn as one. From be1339144514694bc0ba9c573844b1d45bcdd5b2 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 15:27:35 -0500 Subject: [PATCH 08/36] test(automation): cover absent action paths --- .../__tests__/automationDerive.test.ts | 16 ++++++++++++++++ .../automationWritesWhatIsDrawn.test.ts | 18 ++++++++++++++++++ .../components/Automation/automationDerive.ts | 17 ++++++++++++----- .../__tests__/automationTemplates.test.ts | 11 +++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/Automation/__tests__/automationDerive.test.ts b/src/renderer/components/Automation/__tests__/automationDerive.test.ts index 7833c06..7f8e4da 100644 --- a/src/renderer/components/Automation/__tests__/automationDerive.test.ts +++ b/src/renderer/components/Automation/__tests__/automationDerive.test.ts @@ -505,6 +505,22 @@ describe('automationDerive — missing values are marked, not blank', () => { }); describe('automationDerive — the palette summary', () => { + it('describes a webhook-only rule as a post, never a terminal send', () => { + const { action: _action, ...graph } = draftFromTemplate(AUTOMATION_TEMPLATES[0]).graph; + const rule = { + ...draftFromTemplate(AUTOMATION_TEMPLATES[0]), + graph: { + ...graph, + webhook: { provider: 'discord' as const, url: 'https://secret.invalid/hook', body: 'done' }, + }, + }; + + expect(ruleSummary(rule)).toBe( + 'Watching command contains "claude" · when the value in ctx:(\\d+)% is greater than 25 · post discord', + ); + expect(describeRule(rule).verbSend).toBe('post'); + }); + it('describes each template differently, from the same values', () => { const summaries = AUTOMATION_TEMPLATES.map((t) => ruleSummary(draftFromTemplate(t))); expect(new Set(summaries).size).toBe(AUTOMATION_TEMPLATES.length); diff --git a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts index 9628e9d..2d114ac 100644 --- a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts +++ b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts @@ -27,6 +27,24 @@ const blockingCodes = (draft: AutomationDraft): string[] => blockingProblems(problems(ruleFromDraft(draft))).map((p) => p.code); describe('the editor writes the steps the canvas draws', () => { + it('omits a hidden action scaffold from the graph a save serializes', () => { + const draft = draftFromRule(blankDraft(), 'blank'); + expect(draft.rule.graph.action).toBeDefined(); + expect(draft.present).not.toContain('action'); + + const written = JSON.parse(JSON.stringify(ruleFromDraft(draft))); + expect(written.graph).not.toHaveProperty('action'); + }); + + it('ignores an action edit when the draft has no action', () => { + const { action: _action, ...graph } = blankDraft().graph; + const draft = draftFromRule({ ...blankDraft(), graph }, 'blank'); + + const after = draftReducer(draft, { type: 'action', patch: { message: 'do not send' } }); + expect(after).toBe(draft); + expect(after.rule.graph).not.toHaveProperty('action'); + }); + /** * **C1, and the one assertion whose absence hid it.** Built the way the palette builds it — * `addStep` for each card, then the Wait panel's own mode dispatch — and asked of diff --git a/src/renderer/components/Automation/automationDerive.ts b/src/renderer/components/Automation/automationDerive.ts index 448adc0..a656a73 100644 --- a/src/renderer/components/Automation/automationDerive.ts +++ b/src/renderer/components/Automation/automationDerive.ts @@ -638,8 +638,15 @@ function whenPhrase(rule: AutomationRule, pattern: string, cond: Record { expect(second.targetIds).toEqual([]); }); + it('clones a template with no action without creating one', () => { + const { action: _action, ...graph } = AUTOMATION_TEMPLATES[0].rule.graph; + const withoutAction: AutomationTemplate = { + ...AUTOMATION_TEMPLATES[0], + rule: { ...AUTOMATION_TEMPLATES[0].rule, graph }, + }; + + expect(() => draftFromTemplate(withoutAction)).not.toThrow(); + expect(draftFromTemplate(withoutAction).graph).not.toHaveProperty('action'); + }); + /** * **A hand-rolled copy that rebuilds a shape field by field drops the next field silently.** * From c6d148254d50448d4f94054a50a80623e6e27c77 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 15:37:00 -0500 Subject: [PATCH 09/36] feat(automation): add webhook sender --- src-tauri/src/automation_webhook.rs | 317 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 2 files changed, 318 insertions(+) create mode 100644 src-tauri/src/automation_webhook.rs diff --git a/src-tauri/src/automation_webhook.rs b/src-tauri/src/automation_webhook.rs new file mode 100644 index 0000000..616f014 --- /dev/null +++ b/src-tauri/src/automation_webhook.rs @@ -0,0 +1,317 @@ +//! Sending an automation's webhook destination. +//! +//! The webhook URL is deliberately confined to the request builder. In particular, no error type +//! in this module retains a `reqwest::Error`: its `Display` and `Debug` implementations can contain +//! the request URL, which may itself contain a credential. + +use std::time::Duration; + +use crate::automation_store::{WebhookProvider, WebhookStep}; + +/// Bound one webhook attempt. A webhook send is dispatched by the engine; this timeout prevents a +/// peer that accepts a connection but never replies from holding that dispatch indefinitely. +const WEBHOOK_TIMEOUT: Duration = Duration::from_secs(10); + +/// The safe-to-render class of a transport failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebhookTransportClass { + Connect, + Timeout, + Request, +} + +impl std::fmt::Display for WebhookTransportClass { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Connect => f.write_str("connect"), + Self::Timeout => f.write_str("timeout"), + Self::Request => f.write_str("request"), + } + } +} + +/// A webhook failure that is safe to put in an automation activity entry. +/// +/// This intentionally stores only an HTTP status or a coarse transport class. Do not add a +/// `reqwest::Error`, request URL, or response body here: all of those can carry the user's secret +/// endpoint into a display or log surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebhookError { + Transport(WebhookTransportClass), + Status(reqwest::StatusCode), +} + +impl WebhookError { + fn from_transport(error: reqwest::Error) -> Self { + let class = if error.is_timeout() { + WebhookTransportClass::Timeout + } else if error.is_connect() { + WebhookTransportClass::Connect + } else { + WebhookTransportClass::Request + }; + Self::Transport(class) + } +} + +impl std::fmt::Display for WebhookError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transport(class) => write!(f, "webhook transport failed ({class})"), + Self::Status(status) => write!(f, "webhook returned status {status}"), + } + } +} + +impl std::error::Error for WebhookError {} + +/// Send a configured webhook body to its destination. +pub async fn send(webhook: &WebhookStep) -> Result<(), WebhookError> { + send_body(webhook, &webhook.body).await +} + +/// Send a resolved webhook body to its destination. +/// +/// Preset providers always receive a newly-created JSON wrapper. `message` is encoded as a JSON +/// value, never interpolated into a JSON string. A Custom endpoint instead receives `message`'s +/// bytes unchanged, including whitespace and duplicate JSON object keys. The engine uses this only +/// after resolving an opted-in capture substitution; normal callers should use [`send`]. +pub async fn send_body(webhook: &WebhookStep, message: &str) -> Result<(), WebhookError> { + let body = payload(webhook.provider, message); + let client = reqwest::Client::builder() + .timeout(WEBHOOK_TIMEOUT) + .build() + .map_err(WebhookError::from_transport)?; + let response = client + .post(&webhook.url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await + .map_err(WebhookError::from_transport)?; + + if response.status().is_success() { + Ok(()) + } else { + Err(WebhookError::Status(response.status())) + } +} + +fn payload(provider: WebhookProvider, message: &str) -> Vec { + match provider { + WebhookProvider::Discord => serde_json::to_vec(&serde_json::json!({ "content": message })) + .expect("a string is always serializable as JSON"), + WebhookProvider::Slack => serde_json::to_vec(&serde_json::json!({ "text": message })) + .expect("a string is always serializable as JSON"), + WebhookProvider::Teams => serde_json::to_vec(&serde_json::json!({ + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "text": message, + })) + .expect("a string is always serializable as JSON"), + WebhookProvider::Custom => message.as_bytes().to_vec(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::automation_store::AutomationGraph; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver}; + + fn webhook(provider: WebhookProvider, url: String, body: &str) -> WebhookStep { + WebhookStep { + provider, + url, + body: body.to_string(), + substitute: false, + } + } + + /// A one-request loopback server. It is deliberately a local listener rather than a mock so + /// the assertions cover the actual reqwest request bytes without contacting any real network. + fn capture_endpoint() -> (String, Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback capture listener"); + let url = format!( + "http://{}", + listener.local_addr().expect("listener address") + ); + let (sent, received) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept webhook request"); + let request = read_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("reply to webhook request"); + sent.send(request).expect("return captured request"); + }); + (url, received) + } + + fn read_request(stream: &mut TcpStream) -> Vec { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("set capture read timeout"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let mut expected = None; + loop { + let read = stream.read(&mut buffer).expect("read webhook request"); + assert_ne!(read, 0, "client closed before completing request"); + request.extend_from_slice(&buffer[..read]); + if expected.is_none() { + expected = content_length(&request).map(|length| { + request + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .expect("headers terminate") + + 4 + + length + }); + } + if expected.is_some_and(|length| request.len() >= length) { + return request; + } + } + } + + fn content_length(request: &[u8]) -> Option { + let header_end = request.windows(4).position(|bytes| bytes == b"\r\n\r\n")?; + let headers = + std::str::from_utf8(&request[..header_end]).expect("request headers are text"); + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().expect("numeric content length")) + }) + } + + fn captured_body(request: Vec) -> Vec { + let header_end = request + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .expect("headers terminate"); + request[header_end + 4..].to_vec() + } + + fn receive_body(receiver: Receiver>) -> Vec { + captured_body( + receiver + .recv_timeout(Duration::from_secs(3)) + .expect("webhook capture completed"), + ) + } + + #[tokio::test] + async fn discord_wraps_the_message_in_content() { + let (url, captured) = capture_endpoint(); + send(&webhook(WebhookProvider::Discord, url, "build failed")) + .await + .expect("discord webhook succeeds"); + assert_eq!(receive_body(captured), br#"{"content":"build failed"}"#); + } + + #[tokio::test] + async fn slack_wraps_the_message_in_text() { + let (url, captured) = capture_endpoint(); + send(&webhook(WebhookProvider::Slack, url, "build failed")) + .await + .expect("slack webhook succeeds"); + assert_eq!(receive_body(captured), br#"{"text":"build failed"}"#); + } + + #[tokio::test] + async fn teams_uses_a_message_card() { + let (url, captured) = capture_endpoint(); + send(&webhook(WebhookProvider::Teams, url, "build failed")) + .await + .expect("teams webhook succeeds"); + let body: serde_json::Value = + serde_json::from_slice(&receive_body(captured)).expect("teams payload is JSON"); + assert_eq!(body["@type"], "MessageCard"); + assert_eq!(body["text"], "build failed"); + } + + #[tokio::test] + async fn a_custom_endpoint_posts_the_body_verbatim() { + let (url, captured) = capture_endpoint(); + let raw = "{ \"z\" : 1, \"a\" : 2, \"a\" : 3 }\n"; + send(&webhook(WebhookProvider::Custom, url, raw)) + .await + .expect("custom webhook succeeds"); + assert_eq!(receive_body(captured), raw.as_bytes()); + } + + #[tokio::test] + async fn a_capture_containing_json_syntax_cannot_break_a_preset_payload() { + let (url, captured) = capture_endpoint(); + let capture = "quote: \"; slash: \\; newline:\n{\"injected\":true}"; + send(&webhook(WebhookProvider::Discord, url, capture)) + .await + .expect("discord webhook succeeds"); + let body: serde_json::Value = + serde_json::from_slice(&receive_body(captured)).expect("preset payload remains JSON"); + assert_eq!(body, serde_json::json!({ "content": capture })); + assert_eq!(body.as_object().expect("object payload").len(), 1); + } + + #[tokio::test] + async fn a_real_transport_error_never_renders_the_url() { + // Reserving then dropping a loopback port makes a genuine connection failure without any + // traffic leaving this machine. The request URL deliberately contains all three values that + // must not reach a display surface. + let reserved = TcpListener::bind("127.0.0.1:0").expect("reserve an unused loopback port"); + let port = reserved.local_addr().expect("reserved address").port(); + drop(reserved); + let url = format!("http://127.0.0.1:{port}/url-leak-path/credential-token"); + let graph = AutomationGraph { + monitor: None, + parse: None, + cond: None, + timer: None, + action: None, + webhook: Some(webhook( + WebhookProvider::Discord, + url.clone(), + "build failed", + )), + layout: None, + }; + let graph_debug = format!("{graph:?}"); + assert!(graph_debug.contains("url: \"\"")); + assert!(!graph_debug.contains(&url)); + let raw = reqwest::Client::builder() + // Do not let a machine-wide proxy turn this local refusal into a real network request. + .no_proxy() + .timeout(Duration::from_secs(1)) + .build() + .expect("build client") + .post(&url) + .send() + .await + .expect_err("dropped loopback port refuses the request"); + + assert_eq!( + raw.url() + .expect("reqwest attaches the request URL to this transport error") + .as_str(), + url, + "this must be a real URL-bearing reqwest error before conversion" + ); + let rendered = WebhookError::from_transport(raw).to_string(); + for secret in [&url, "127.0.0.1", "url-leak-path"] { + assert!( + !rendered.contains(secret), + "safe webhook error leaked {secret:?}: {rendered:?}" + ); + } + assert!( + rendered.starts_with("webhook transport failed ("), + "the error renders only its coarse class: {rendered:?}" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 962c8d0..80919d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ pub mod automation_commands; pub mod automation_engine; pub mod automation_store; pub mod automation_validation; +pub mod automation_webhook; pub mod canvas_endpoints; pub mod network_commands; pub mod pty_manager; From d0eda1ca150fc102cef22cef7d8943922aacc902 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 17:01:18 -0500 Subject: [PATCH 10/36] feat(automation): dispatch webhook destinations --- src-tauri/src/automation_engine/loops.rs | 1707 +++++++++++++++++----- 1 file changed, 1352 insertions(+), 355 deletions(-) diff --git a/src-tauri/src/automation_engine/loops.rs b/src-tauri/src/automation_engine/loops.rs index c819fe0..3a0a4d0 100644 --- a/src-tauri/src/automation_engine/loops.rs +++ b/src-tauri/src/automation_engine/loops.rs @@ -11,9 +11,9 @@ //! milestones that contain the entire engine. use std::collections::{BTreeSet, HashMap, HashSet}; -use std::sync::atomic::Ordering; #[cfg(test)] use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; @@ -24,7 +24,9 @@ use crate::automation::targeting::watched_set; use crate::automation_engine::due::{ due_now, select_due, settled_processes, BASE_TICK_MS, MAX_EVALS_PER_TICK, TARGETING_TICK_MS, }; -use crate::automation_engine::eval::{self, ArmState, Captures, Decision, Evaluation, Outcome, Read}; +use crate::automation_engine::eval::{ + self, ArmState, Captures, Decision, Evaluation, Outcome, Read, +}; use crate::automation_engine::host::{EngineHost, HostPort}; use crate::automation_engine::schedule; use crate::automation_engine::subst; @@ -74,7 +76,10 @@ pub async fn run_tap( // so is one extra evaluation each; the cost of dropping it is a missed match with no // symptom. `Lagged` is exactly why the tap carries a signal instead of the bytes. Ok(Err(RecvError::Lagged(n))) => { - log::warn!("automations: tap lagged {} messages, marking every terminal dirty", n); + log::warn!( + "automations: tap lagged {} messages, marking every terminal dirty", + n + ); for pc in host.live_processes() { engine.runtime.mark_dirty(&pc); } @@ -84,6 +89,322 @@ pub async fn run_tap( } } +#[cfg(test)] +mod task8_tests { + use super::*; + use crate::automation_engine::test_host::{ctx_rule, rig_with_rule, strip_comments, wire}; + use crate::automation_store::{LogOrder, LogScope, WebhookProvider, WebhookStep}; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver, Sender}; + + fn pending( + engine: &Arc, + host: &Arc, + prev: ArmState, + at_ms: i64, + ) -> PendingSend { + let rule = engine + .snapshot_live() + .into_iter() + .next() + .expect("live rule"); + engine + .runtime + .set_arm(&rule.rule.id, "tm-1", ArmState::Fired { at_ms }); + PendingSend { + pair: Pair { + rule, + tm: "tm-1".into(), + pc: "pc-1".into(), + }, + prev, + label: host.label_for("tm-1"), + at_ms, + captures: None, + } + } + + fn webhook_endpoint() -> (String, Receiver<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback webhook listener"); + let url = format!( + "http://{}", + listener.local_addr().expect("listener address") + ); + let (sent, received) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept webhook request"); + read_webhook_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("reply"); + sent.send(()).expect("record webhook request"); + }); + (url, received) + } + + fn held_webhook_endpoint() -> (String, Sender<()>, Receiver<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback webhook listener"); + let url = format!( + "http://{}", + listener.local_addr().expect("listener address") + ); + let (release_sent, release_received) = mpsc::channel(); + let (arrived_sent, arrived_received) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept webhook request"); + read_webhook_request(&mut stream); + arrived_sent.send(()).expect("record webhook arrival"); + release_received.recv().expect("release webhook response"); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("reply"); + }); + (url, release_sent, arrived_received) + } + + fn read_webhook_request(stream: &mut TcpStream) { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("read timeout"); + let mut buffer = [0_u8; 4096]; + assert_ne!(stream.read(&mut buffer).expect("read request"), 0); + } + + fn add_discord_webhook(graph: &mut crate::automation_store::AutomationGraph, url: String) { + graph.webhook = Some(WebhookStep { + provider: WebhookProvider::Discord, + url, + body: "webhook body".into(), + substitute: false, + }); + } + + // ============================================================================================= + // Task 8 — one crossing, two destinations + // ============================================================================================= + + /// Normal live-terminal scenario: the terminal stays live after the crossing is decided, but + /// its malformed webhook endpoint fails. That failure must not suppress the terminal delivery. + #[tokio::test] + async fn a_failed_webhook_leaves_the_terminal_send_alone() { + let (engine, fake, host) = rig_with_rule(|graph| { + // reqwest rejects this while building the request; it cannot leave this machine. + add_discord_webhook(graph, "not a valid URL".into()); + }); + let send = pending(&engine, &host, ArmState::armed(), 4_000); + + run_crossing(engine.clone(), host.clone(), send).await; + + assert!( + fake.written() + .iter() + .any(|write| write.contains("prepare to do context-hand-off")), + "the terminal destination was suppressed by a failed webhook: {:?}", + fake.written() + ); + let rows = fake + .store + .load_automation_log(&LogScope::All, LogOrder::Asc, 10) + .unwrap(); + assert_eq!( + rows.len(), + 2, + "each destination writes its own outcome: {rows:?}" + ); + assert!(rows + .iter() + .any(|row| row.kind == LogKind::Sent && row.terminal_id.as_deref() == Some("tm-1"))); + assert!(rows + .iter() + .any(|row| row.kind == LogKind::Failed && row.terminal_id.is_none())); + } + + /// Already-decided / terminal-closed scenario from the scope note: the leaf disappears after + /// the crossing exists, so the terminal fails, but the webhook still sends once and retires the + /// runs-once rule rather than rolling the arm back for a repeat. + #[tokio::test] + async fn a_failed_terminal_send_does_not_let_the_webhook_repeat() { + let (url, requested) = webhook_endpoint(); + let mut rule = ctx_rule("au-1"); + rule.runs_once = true; + add_discord_webhook(&mut rule.graph, url); + let (engine, fake, host) = wire(vec![rule]); + let send = pending(&engine, &host, ArmState::armed(), 4_000); + fake.close("tm-1"); + + run_crossing(engine.clone(), host.clone(), send).await; + + requested + .recv_timeout(Duration::from_secs(3)) + .expect("the webhook was sent after the terminal closed"); + assert!( + !engine.is_live("au-1"), + "the successful webhook completed the crossing once" + ); + let rows = fake + .store + .load_automation_log(&LogScope::All, LogOrder::Asc, 10) + .unwrap(); + assert!(rows + .iter() + .any(|row| row.kind == LogKind::Failed && row.terminal_id.as_deref() == Some("tm-1"))); + assert!(rows + .iter() + .any(|row| row.kind == LogKind::Sent && row.terminal_id.is_none())); + } + + /// Normal live-terminal scenario: both destinations succeed for one already-decided crossing, + /// so its fire history increments once rather than once per destination. + #[tokio::test] + async fn a_crossing_records_one_fire_however_many_destinations_it_had() { + let (url, requested) = webhook_endpoint(); + let (engine, _fake, host) = rig_with_rule(|graph| add_discord_webhook(graph, url)); + let send = pending(&engine, &host, ArmState::armed(), 4_000); + + run_crossing(engine.clone(), host.clone(), send).await; + + requested + .recv_timeout(Duration::from_secs(3)) + .expect("webhook request"); + assert_eq!( + engine.runtime.fire_record("au-1", "tm-1"), + Some((1, 4_000)), + "two destinations are one crossing, not two fires" + ); + } + + /// Normal live-terminal scenario: the terminal has finished its 500 ms delivery while the + /// webhook deliberately waits for its local response. Completion must wait for that response. + #[tokio::test] + async fn completion_waits_for_every_destination_not_the_first() { + let (url, release, arrived) = held_webhook_endpoint(); + let mut rule = ctx_rule("au-1"); + rule.runs_once = true; + add_discord_webhook(&mut rule.graph, url); + let (engine, fake, host) = wire(vec![rule]); + let send = pending(&engine, &host, ArmState::armed(), 4_000); + let task = tokio::spawn(run_crossing(engine.clone(), host.clone(), send)); + + tokio::time::sleep(Duration::from_millis(100)).await; + arrived + .try_recv() + .expect("webhook request reached the held listener"); + tokio::time::sleep(Duration::from_millis(650)).await; + assert!( + fake.written() + .iter() + .any(|write| write.contains("prepare to do context-hand-off")), + "the terminal destination did not complete" + ); + assert!( + engine.is_live("au-1"), + "the first completed destination retired the rule early" + ); + assert!( + !task.is_finished(), + "the crossing returned before the held webhook did" + ); + + release.send(()).expect("release webhook response"); + task.await.expect("crossing task"); + assert!( + !engine.is_live("au-1"), + "completion did not follow both destination outcomes" + ); + } + + /// Normal live-terminal scenario: this is the strong two-row oracle. Both rows must name the + /// same rule and decision timestamp, while their terminal identity, provider identity, kind, + /// and observed delivery outcomes distinguish a terminal send from a webhook send. + #[tokio::test] + async fn one_crossing_with_two_destinations_writes_one_row_each() { + let (url, requested) = webhook_endpoint(); + let (engine, fake, host) = rig_with_rule(|graph| add_discord_webhook(graph, url)); + let send = pending(&engine, &host, ArmState::armed(), 4_242); + + run_crossing(engine.clone(), host.clone(), send).await; + + requested + .recv_timeout(Duration::from_secs(3)) + .expect("webhook request"); + assert!( + fake.written() + .iter() + .any(|write| write.contains("prepare to do context-hand-off")), + "the terminal delivery did not occur" + ); + let rows = fake + .store + .load_automation_log(&LogScope::All, LogOrder::Asc, 10) + .unwrap(); + assert_eq!(rows.len(), 2, "not a bare count: inspect both rows below"); + let terminal = rows + .iter() + .find(|row| row.terminal_id.as_deref() == Some("tm-1")) + .expect("terminal destination row"); + assert_eq!(terminal.rule_id, "au-1"); + assert_eq!(terminal.at, 4_242); + assert_eq!(terminal.kind, LogKind::Sent); + assert_eq!(terminal.terminal_name.as_deref(), Some("codex · core")); + assert_eq!(terminal.detail, "sent to codex · core"); + + let webhook = rows + .iter() + .find(|row| row.terminal_id.is_none()) + .expect("webhook destination row"); + assert_eq!(webhook.rule_id, "au-1"); + assert_eq!(webhook.at, 4_242); + assert_eq!(webhook.kind, LogKind::Sent); + assert_eq!(webhook.terminal_name, None); + assert_eq!(webhook.detail, "webhook sent via Discord"); + } + + /// Source-derived rather than behavioural: the dispatch code has no clock of its own, and the + /// webhook sibling never takes `send_lock`. This test exercises no terminal scenario. + #[test] + fn the_webhook_path_adds_no_new_clock() { + let source = strip_comments(include_str!("loops.rs")); + let crossing_start = source + .rfind("async fn run_crossing(") + .expect("crossing function"); + let webhook_start = source + .rfind("async fn run_webhook(") + .expect("webhook function"); + let terminal_start = source + .rfind("async fn run_send(") + .expect("terminal function"); + let completion_start = source + .rfind("fn complete_crossing(") + .expect("completion function"); + let crossing = &source[crossing_start..terminal_start]; + let webhook = &source[webhook_start..completion_start]; + assert!( + crossing.contains("tokio::join!"), + "destinations must be aggregated together" + ); + for body in [crossing, webhook] { + assert!( + !body.contains("tokio::time::interval"), + "a webhook path added an interval" + ); + assert!( + !body.contains("tokio::time::sleep"), + "a webhook path added a sleep" + ); + } + assert!( + !webhook.contains("send_lock"), + "a webhook must not queue behind a terminal send" + ); + assert!(crossing.contains("run_send") && crossing.contains("run_webhook")); + } +} + // ================================================================================================= // The evaluator (§2.3) // ================================================================================================= @@ -215,7 +536,11 @@ pub async fn evaluator_step( // the pair's *"Waiting to send"* pill sits on a countdown that reached zero and stopped, // until something unrelated repaints it. Marked and not emitted: one drain point, one rate // limit, and `evaluate_tick` below is that point. - if engine.runtime.drop_stale_parked(now_ms, crate::automation_validation::MAX_DELAY_MS) > 0 { + if engine + .runtime + .drop_stale_parked(now_ms, crate::automation_validation::MAX_DELAY_MS) + > 0 + { engine.mark_state_dirty(); } } @@ -255,7 +580,11 @@ pub async fn evaluate_tick( let now_local = schedule::local_now(now_ms); for live in engine.snapshot_live() { // Sorted, so which pairs the cap holds over is a property of the rule and not of hash order. - let mut leaves: Vec = engine.runtime.watched_for(&live.rule.id).into_iter().collect(); + let mut leaves: Vec = engine + .runtime + .watched_for(&live.rule.id) + .into_iter() + .collect(); leaves.sort(); // **§6.3's two rule-level facts, decided BEFORE the leaves.** // @@ -278,11 +607,17 @@ pub async fn evaluate_tick( // crossing as well as on the clock. `schedule_due` is deliberately false for `AfterMatch`, so // it cannot double as the "is this a schedule rule" question: that is what `scheduled` is. let scheduled = match &live.rule.graph.timer { - Some(TimerStep { mode: mode @ TimerMode::DailyAt { .. } }) => Some(mode), + Some(TimerStep { + mode: mode @ TimerMode::DailyAt { .. }, + }) => Some(mode), _ => None, }; let fires_now = scheduled.is_some_and(|mode| { - schedule::schedule_due(mode, engine.runtime.last_fired_day(&live.rule.id), now_local) + schedule::schedule_due( + mode, + engine.runtime.last_fired_day(&live.rule.id), + now_local, + ) }); for tm in leaves { // The ONE tm -> pc conversion. `None` is dormant (§4.5), not dead: no evaluation, no log @@ -332,21 +667,22 @@ pub async fn evaluate_tick( // message it is about to type. if let Some(parked) = engine.runtime.take_parked_due(&live.rule.id, &tm, now_ms) { admit( - engine, - host, &mut sends, PendingSend { // **`parked.pc`, never the `pc` this tick just resolved.** The restart // guard in `run_send` compares the leaf's process at lock time against // this field; filled from the drain's own lookup it compares a value // against itself and the whole park is unguarded. - pair: Pair { rule: live.clone(), tm: tm.clone(), pc: parked.pc }, + pair: Pair { + rule: live.clone(), + tm: tm.clone(), + pc: parked.pc, + }, prev: parked.prev, label: parked.label, at_ms: now_ms, captures: parked.captures, }, - now_ms, ); } let seq = engine.runtime.dirty_seq(&pc); @@ -376,11 +712,13 @@ pub async fn evaluate_tick( if scheduled.is_some() { if fires_now { admit( - engine, - host, &mut sends, PendingSend { - pair: Pair { rule: live.clone(), tm: tm.clone(), pc }, + pair: Pair { + rule: live.clone(), + tm: tm.clone(), + pc, + }, // **Read, not assumed.** `prev` is what `run_send`'s three failure paths // roll back to, and a schedule rule has no crossing to roll back to — so // the only correct target is whatever is already there, which makes @@ -402,7 +740,6 @@ pub async fn evaluate_tick( // template into a live agent. captures: None, }, - now_ms, ); } continue; @@ -432,7 +769,11 @@ pub async fn evaluate_tick( engine.runtime.last_eval(&live.rule.id, &tm), now_ms, ) { - due.push(Pair { rule: live.clone(), tm, pc }); + due.push(Pair { + rule: live.clone(), + tm, + pc, + }); } else if monitor.cadence == Cadence::OnOutput { // No `seq.is_some()` here, deliberately: a CLEAN process contributes no due pair, so // it never reaches `due_pcs` and `settled_processes` can never name it — the extra @@ -456,7 +797,9 @@ pub async fn evaluate_tick( let Some(TimerMode::DailyAt { minute_of_day, .. }) = scheduled else { unreachable!("fires_now requires a daily schedule"); }; - engine.runtime.set_last_fired_day(&live.rule.id, now_local.day_ordinal, *minute_of_day); + engine + .runtime + .set_last_fired_day(&live.rule.id, now_local.day_ordinal, *minute_of_day); } } @@ -472,7 +815,7 @@ pub async fn evaluate_tick( for i in &picked { match evaluate_pair(engine, host, &due[*i], now_ms) { - Evaluated::Read(Some(send)) => admit(engine, host, &mut sends, send, now_ms), + Evaluated::Read(Some(send)) => admit(&mut sends, send), // Read, decided, nothing to send: this pair has consumed the output and may spend it. Evaluated::Read(None) => {} // **The third door.** `settled_processes`'s enumeration named two and this was neither: @@ -497,14 +840,16 @@ pub async fn evaluate_tick( // sends in one tick would freeze evaluation for two seconds. Serialisation is unaffected: it was // never the tick that provided it, it was the per-terminal lock. for send in sends { - // A webhook-only rule has no terminal destination. Never dispatch `run_send` for it: an - // invented empty action can submit a bare Enter to a live terminal. - if send.pair.rule.rule.graph.action.is_none() { + // A crossing owns its destinations and all of their shared bookkeeping. In particular, a + // webhook-only rule has no terminal destination: never manufacture an ActionStep merely to + // route it through `run_send`, because an empty action can submit a bare Enter. + if send.pair.rule.rule.graph.action.is_none() && send.pair.rule.rule.graph.webhook.is_none() + { continue; } let engine = engine.clone(); let host = host.clone(); - tokio::spawn(async move { run_send(engine, host, send).await }); + tokio::spawn(async move { run_crossing(engine, host, send).await }); } if engine.take_state_emit(now_ms) { @@ -514,59 +859,13 @@ pub async fn evaluate_tick( next_cursor } -/// Put one decided send on this tick's dispatch list, if R6 lets it through. -/// -/// **R6 is per RULE — not per pair, and not per TICK.** A `runs_once` rule watching three terminals -/// crosses on all three, and the send lock is per LEAF, so three tasks take three different locks -/// and three messages go out on a rule the user asked to run once. -/// -/// The claim is taken HERE, where the crossing is decided. The first version of this scanned the -/// current tick's `sends` vector, which covers the three-in-one-tick case and nothing else: two -/// terminals crossing on consecutive ticks are two separate vectors, and the only cross-tick guard -/// was `is_live`, which does not go false until `complete_rule` runs — after `deliver` returns, two -/// ticks later. The arm states advance either way; only the send is dropped, so a pair that did not -/// send stays `Fired` and never sends. +/// Put one decided crossing on this tick's dispatch list. /// -/// **It is a function because there are now two routes onto that list**, and a gate written at one -/// caller is a gate the next caller opts out of. §6.2's parked sends are the second route, and they -/// need it more than the first: a `runs_once` rule with a delay parks on every terminal that -/// crosses *during* the wait — the arm machine cannot stop that, because those are different pairs — -/// and all of them come ripe on the same tick, where without this they would be three sends. -fn admit( - engine: &Arc, - host: &Arc, - sends: &mut Vec, - send: PendingSend, - now_ms: i64, -) { - let rule_id = &send.pair.rule.rule.id; - if !send.pair.rule.rule.runs_once || engine.runtime.claim_once(rule_id) { - sends.push(send); - return; - } - // **A dropped crossing that says nothing is a crossing the user cannot account for.** The arm - // state advanced at decide time and the parked entry was taken out of the map by - // `take_parked_due`, so this pair is finished either way — and on the delay route it had been - // visibly *"Waiting to send"* for up to ten minutes first. Without a row the only trace of it is - // a countdown that stopped. - // - // `Held` and not `Failed`, for the seeding row's reason: nothing went wrong. The rule was asked - // and the rule declined, which is also what keeps it out of the verbose gate. It is bounded by - // the claim itself — one row per losing pair per crossing, and a claimed `runs_once` rule - // leaves the live set as soon as its send lands. - // - // Written HERE rather than at the parked drain that found it, because `admit` is the one gate - // all three routes go through and a row written at one caller is a row the next caller opts out - // of — the same reasoning that put the claim itself in this function. - append( - host, - rule_id, - Some(&send.pair.tm), - send.label.clone(), - LogKind::Held, - "not sent — this rule runs once, and another terminal had already claimed its one send", - now_ms, - ); +/// Both immediate and parked crossings use this one dispatch seam. Admission, including the +/// runs-once claim, belongs to `run_crossing`: it is crossing-wide bookkeeping rather than a +/// property of either destination. +fn admit(sends: &mut Vec, send: PendingSend) { + sends.push(send); } /// What one pair's evaluation leaves for the tick to do. @@ -620,15 +919,9 @@ pub fn evaluate_pair( return Evaluated::Unread; }; - let Some(ev): Option = eval::evaluate( - steps, - re, - &echoes, - prev, - &port, - &pair.pc, - now_ms, - ) else { + let Some(ev): Option = + eval::evaluate(steps, re, &echoes, prev, &port, &pair.pc, now_ms) + else { // `host.tail` found no parser for this process — it closed between this tick's leaf // resolution and the read. §4.5: no evaluation, no row, arm state untouched. `set_last_eval` // is deliberately not reached either, so the pair is due again immediately. @@ -649,12 +942,22 @@ pub fn evaluate_pair( } let repeat = engine.runtime.last_decision(&rule.id, &pair.tm) == Some(ev.decision); - engine.runtime.set_last_decision(&rule.id, &pair.tm, ev.decision); + engine + .runtime + .set_last_decision(&rule.id, &pair.tm, ev.decision); if !ev.decision.sends() { // Live by construction: `evaluate_pair` only runs for a pair whose leaf just resolved. let name = host.label_for(&pair.tm); - append(host, &rule.id, Some(&pair.tm), name, kind_for(&ev, repeat), &ev.detail, now_ms); + append( + host, + &rule.id, + Some(&pair.tm), + name, + kind_for(&ev, repeat), + &ev.detail, + now_ms, + ); return Evaluated::Read(None); } @@ -665,7 +968,10 @@ pub fn evaluate_pair( // it. `Read(None)` and not `Unread`, because this pair genuinely READ the terminal's output — // that read is how it found the match — so the dirty flag is spent exactly as it would have // been by a send. - if let Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms } }) = &rule.graph.timer { + if let Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms }, + }) = &rule.graph.timer + { engine.runtime.park( &rule.id, &pair.tm, @@ -731,24 +1037,155 @@ fn kind_for(ev: &Evaluation, repeat: bool) -> LogKind { } // ================================================================================================= -// The send (§2.5, §2.6) +// The crossing and its destinations (§2.5, §2.6, A8) // ================================================================================================= -/// Take the terminal's queue, re-check it is still there, write, and record what happened. +/// The result of one destination, deliberately separate from the crossing's shared state. +enum DestinationOutcome { + Sent, + Failed(String), + Stopped, +} + +/// Dispatch both destinations for one already-decided crossing, then perform its bookkeeping once. /// -/// **Every failure path rolls the arm state back and writes exactly one log line** — the queue timed -/// out, the terminal closed between the decision and the write, the write itself failed. Never left -/// `Fired`, because a crossing that produced no message must still be able to fire. -pub async fn run_send( - engine: Arc, - host: Arc, - send: PendingSend, -) { +/// A terminal delivery and a webhook are independent side effects: one failure must not prevent the +/// other from running. Their common effects — the fire history, runs-once completion, and rollback — +/// are therefore deliberately below the join, where there is one answer for the crossing rather than +/// one answer per destination. +async fn run_crossing(engine: Arc, host: Arc, send: PendingSend) { + let rule = &send.pair.rule.rule; + if engine.stopping.load(Ordering::Relaxed) { + rollback_crossing(&engine, &send); + return; + } + + // R6 is per crossing, not per terminal destination. This task begins at the one shared + // boundary before the concurrent sends, so a second crossing cannot send either destination + // after the first one has claimed the rule. + if rule.runs_once && !engine.runtime.claim_once(&rule.id) { + append( + &host, + &rule.id, + Some(&send.pair.tm), + send.label.clone(), + LogKind::Held, + "not sent — this rule runs once, and another terminal had already claimed its one send", + send.at_ms, + ); + return; + } + + let has_action = rule.graph.action.is_some(); + let has_webhook = rule.graph.webhook.is_some(); + let (terminal, webhook) = tokio::join!( + async { + if has_action { + Some(run_send(&engine, &host, &send).await) + } else { + None + } + }, + async { + if has_webhook { + Some(run_webhook(&engine, &send).await) + } else { + None + } + }, + ); + + let outcomes = [terminal, webhook]; + if outcomes + .iter() + .flatten() + .any(|outcome| matches!(outcome, DestinationOutcome::Stopped)) + { + rollback_crossing(&engine, &send); + return; + } + + let mut sent = false; + if let Some(outcome) = outcomes[0].as_ref() { + match outcome { + DestinationOutcome::Sent => { + sent = true; + append( + &host, + &rule.id, + Some(&send.pair.tm), + send.label.clone(), + LogKind::Sent, + &sent_detail(&send), + send.at_ms, + ); + } + DestinationOutcome::Failed(reason) => append( + &host, + &rule.id, + Some(&send.pair.tm), + send.label.clone(), + LogKind::Failed, + reason, + send.at_ms, + ), + DestinationOutcome::Stopped => unreachable!("stopped outcomes returned above"), + } + } + if let Some(outcome) = outcomes[1].as_ref() { + match outcome { + DestinationOutcome::Sent => { + sent = true; + append( + &host, + &rule.id, + None, + None, + LogKind::Sent, + &webhook_sent_detail(&send), + send.at_ms, + ); + } + DestinationOutcome::Failed(reason) => append( + &host, + &rule.id, + None, + None, + LogKind::Failed, + reason, + send.at_ms, + ), + DestinationOutcome::Stopped => unreachable!("stopped outcomes returned above"), + } + } + + if !sent { + rollback_crossing(&engine, &send); + return; + } + + // One crossing records one fire however many destinations completed. A terminal echo/settle + // remains owned by `run_send`, because only terminal bytes can be echoed back into a screen. + engine + .runtime + .record_fire(&rule.id, &send.pair.tm, send.at_ms); + complete_crossing(&engine, &host, &send); + engine.mark_state_dirty(); +} + +/// Take the terminal's queue, re-check it is still there, and write its destination. +/// +/// This function has no crossing bookkeeping: `run_crossing` aggregates its result with the webhook +/// before it records a fire, completes a runs-once rule, or rolls an arm back. +async fn run_send( + engine: &Arc, + host: &Arc, + send: &PendingSend, +) -> DestinationOutcome { let rule = &send.pair.rule.rule; let tm = send.pair.tm.clone(); - // Belt and braces for future callers that bypass the dispatch guard. let Some(action) = rule.graph.action.as_ref() else { - return; + return DestinationOutcome::Failed("the rule has no terminal destination".into()); }; // **Before the queue.** §2.6 layer 2 runs for `ECHO_SETTLE_MS` after the WRITE, and the wait for // this terminal's lock is up to `SEND_QUEUE_TIMEOUT_MS` of the distance between the decision and @@ -758,21 +1195,20 @@ pub async fn run_send( let began = tokio::time::Instant::now(); let lock = engine.runtime.send_lock(&tm); - let _guard = match tokio::time::timeout( - Duration::from_millis(SEND_QUEUE_TIMEOUT_MS), - lock.lock(), - ) - .await - { - Ok(guard) => guard, - Err(_) => { - return fail(&engine, &host, &send, "another rule was still sending"); - } - }; + let _guard = + match tokio::time::timeout(Duration::from_millis(SEND_QUEUE_TIMEOUT_MS), lock.lock()).await + { + Ok(guard) => guard, + Err(_) => { + return DestinationOutcome::Failed("another rule was still sending".into()); + } + }; // Inside the lock, because the terminal can close between the decision and our turn at the queue. let Some(pc) = host.process_for_leaf(&tm) else { - return fail(&engine, &host, &send, "the terminal closed before the message was sent"); + return DestinationOutcome::Failed( + "the terminal closed before the message was sent".into(), + ); }; // **And it can close AND COME BACK, which resolving by leaf alone cannot see.** `tm-` is durable @@ -790,7 +1226,9 @@ pub async fn run_send( // carries the crossing's process across that wait — built from the drain's own lookup instead, // this comparison would be a value against itself for every delayed rule. if pc != send.pair.pc { - return fail(&engine, &host, &send, "the terminal restarted before the message was sent"); + return DestinationOutcome::Failed( + "the terminal restarted before the message was sent".into(), + ); } // And so can the RULE. The queue wait is up to ten seconds, and a user who disables a rule inside @@ -802,21 +1240,15 @@ pub async fn run_send( // that completed on another terminal, and told the user their rule had been turned off when // nobody had touched it. if !engine.is_live(&rule.id) { - return fail(&engine, &host, &send, "the rule was turned off before the message was sent"); + return DestinationOutcome::Failed( + "the rule was turned off before the message was sent".into(), + ); } // §2.1: checked before the FIRST write and never between the paste and the submit, so a quit // leaves the send either unstarted or complete — there is no half-typed line to reason about. if engine.stopping.load(Ordering::Relaxed) { - // The same rollback as `fail`, minus the row — the app is going down and the store is - // closing. It was the one rollback of three that announced nothing, so a pill caught - // mid-transition stayed on whatever it had last painted. - engine.runtime.restore_arm(&rule.id, &tm, send.prev); - if rule.runs_once { - engine.runtime.release_once(&rule.id); - } - engine.mark_state_dirty(); - return; + return DestinationOutcome::Stopped; } let body = if action.substitute { @@ -826,12 +1258,9 @@ pub async fn run_send( // the "unintended content" this whole feature exists to prevent, and a refusal that is // logged is the safe fallback it asks for instead. Err(e) => { - return fail( - &engine, - &host, - &send, - &format!("nothing sent — {e} had no value at the moment it fired"), - ); + return DestinationOutcome::Failed(format!( + "nothing sent — {e} had no value at the moment it fired" + )); } } } else { @@ -843,14 +1272,17 @@ pub async fn run_send( &HostPort(host.as_ref()), &pc, &action.cli_type, - crate::automation::send::SubmitPattern { separator, end_indicator }, + crate::automation::send::SubmitPattern { + separator, + end_indicator, + }, &body, action.submit, ) .await; if let Err(e) = outcome { - return fail(&engine, &host, &send, &format!("the message could not be sent: {}", e)); + return DestinationOutcome::Failed(format!("the message could not be sent: {}", e)); } let at = send.at_ms; @@ -863,14 +1295,57 @@ pub async fn run_send( // still strips it. The needle is `body` — what actually reached the terminal — never // `action.message`: with substitution on, the terminal echoes the RESOLVED text, and a needle // still carrying `$1` would never match it. - engine.runtime.push_echo(&tm, &crate::automation::send::normalise(&body), landed); + engine + .runtime + .push_echo(&tm, &crate::automation::send::normalise(&body), landed); engine.runtime.settle_until(&tm, landed + ECHO_SETTLE_MS); - engine.runtime.record_fire(&rule.id, &tm, at); + DestinationOutcome::Sent +} - let name = send.label.clone(); - append(&host, &rule.id, Some(&tm), name, LogKind::Sent, &sent_detail(&send), at); +/// Post the webhook destination for this crossing. It intentionally never reaches the terminal +/// queue lock: a slow endpoint must not serialise terminal writes, and a terminal failure must not +/// suppress an already-decided webhook. +async fn run_webhook(engine: &Arc, send: &PendingSend) -> DestinationOutcome { + let rule = &send.pair.rule.rule; + let Some(webhook) = rule.graph.webhook.as_ref() else { + return DestinationOutcome::Failed("the rule has no webhook destination".into()); + }; + if engine.stopping.load(Ordering::Relaxed) { + return DestinationOutcome::Stopped; + } + if !engine.is_live(&rule.id) { + return DestinationOutcome::Failed( + "the rule was turned off before the webhook was sent".into(), + ); + } + let body = if webhook.substitute { + match subst::substitute(&webhook.body, send.captures.as_ref()) { + Ok(body) => body, + Err(e) => { + return DestinationOutcome::Failed(format!( + "webhook not sent — {e} had no value at the moment it fired" + )) + } + } + } else { + webhook.body.clone() + }; + match crate::automation_webhook::send_body(webhook, &body).await { + Ok(()) => DestinationOutcome::Sent, + Err(error) => DestinationOutcome::Failed(format!("webhook failed: {error}")), + } +} - // §7.8 — completion is an in-memory event FIRST and a row second, in this same critical section. +/// Complete a successful crossing once, after every destination has returned. +fn complete_crossing( + engine: &Arc, + host: &Arc, + send: &PendingSend, +) { + let rule = &send.pair.rule.rule; + let tm = &send.pair.tm; + let at = send.at_ms; + // §7.8 — completion is an in-memory event FIRST and a row second, after every destination. // `reload` runs from mutating store commands and this is the engine, which is not one: without // the in-memory removal the rule stays live in `Fired`, re-arms the moment its value drops, and // sends a SECOND message in the same session from a row the UI already shows as Completed. @@ -901,7 +1376,7 @@ pub async fn run_send( append( &host, &rule.id, - Some(&tm), + Some(tm), None, LogKind::Failed, "fired, but its completion could not be recorded — it may run again after a restart", @@ -926,7 +1401,6 @@ pub async fn run_send( // the thing that failed, which makes disk stale rather than true. host.emit_changed(vec![rule.id.clone()]); } - engine.mark_state_dirty(); } fn sent_detail(send: &PendingSend) -> String { @@ -936,15 +1410,25 @@ fn sent_detail(send: &PendingSend) -> String { } } -/// One failure: roll the arm state back to exactly where it was, and say so once. -fn fail( - engine: &Arc, - host: &Arc, - send: &PendingSend, - reason: &str, -) { +fn webhook_sent_detail(send: &PendingSend) -> String { + let provider = send + .pair + .rule + .rule + .graph + .webhook + .as_ref() + .expect("a webhook outcome requires a webhook step") + .provider; + format!("webhook sent via {provider:?}") +} + +/// Roll one wholly failed crossing back to exactly the arm state it had when it was decided. +fn rollback_crossing(engine: &Arc, send: &PendingSend) { let rule_id = &send.pair.rule.rule.id; - engine.runtime.restore_arm(rule_id, &send.pair.tm, send.prev); + engine + .runtime + .restore_arm(rule_id, &send.pair.tm, send.prev); // A rollback restores; it never creates. The claim was taken when this crossing was DECIDED, so a // crossing that produced no message must give it back — otherwise one queue timeout retires a // single-run rule that has never sent anything. @@ -952,17 +1436,6 @@ fn fail( engine.runtime.release_once(rule_id); } engine.mark_state_dirty(); - append( - host, - rule_id, - Some(&send.pair.tm), - // The label resolved at DECIDE time. This is the whole reason `PendingSend` carries one: - // `the terminal closed` is written when there is no name left to look up. - send.label.clone(), - LogKind::Failed, - reason, - send.at_ms, - ); } /// Append one row and emit if the store says one is due. @@ -997,7 +1470,11 @@ fn append( match host.store().append(&entry) { Ok(Some(outcome)) if outcome.emit => host.emit_activity(outcome.rule_ids), Ok(_) => {} - Err(e) => log::warn!("automations: could not write a log row for {}: {}", rule_id, e), + Err(e) => log::warn!( + "automations: could not write a log row for {}: {}", + rule_id, + e + ), } } @@ -1015,8 +1492,10 @@ pub async fn run_targeting(engine: Arc, host: Arc>, HashMap>) = - (HashMap::new(), HashMap::new()); + let mut last: ( + HashMap>, + HashMap>, + ) = (HashMap::new(), HashMap::new()); loop { if engine.stopping.load(Ordering::Relaxed) { return; @@ -1025,7 +1504,8 @@ pub async fn run_targeting(engine: Arc, host: Arc pass, Err(e) => { // `unwrap_or_default()` here turned a panicked roster pass into an EMPTY one, which @@ -1088,9 +1568,14 @@ pub fn targeting_tick( let rows = host.roster(&criteria); // Indexed once, outside the rule loop: the snapshot walk below wants the row for a terminal it // already knows it watches, and scanning the whole roster per rule made that `rules × roster`. - let by_id: HashMap<&str, &crate::automation::roster::RosterRow> = - rows.iter().filter_map(|r| r.terminal_id.as_deref().map(|t| (t, r))).collect(); - let live_leaves: HashSet<&str> = rows.iter().filter_map(|r| r.terminal_id.as_deref()).collect(); + let by_id: HashMap<&str, &crate::automation::roster::RosterRow> = rows + .iter() + .filter_map(|r| r.terminal_id.as_deref().map(|t| (t, r))) + .collect(); + let live_leaves: HashSet<&str> = rows + .iter() + .filter_map(|r| r.terminal_id.as_deref()) + .collect(); let grace_over = crate::automation::roster::grace_elapsed(now_ms, engine.started_at_ms()); let mut missing = HashMap::new(); let mut watched: HashMap> = HashMap::new(); @@ -1103,7 +1588,9 @@ pub fn targeting_tick( // consumer.) let previous: BTreeSet = engine.runtime.watched_for(id).into_iter().collect(); let next = watched_set(&live.rule, &rows, Some(&previous)); - engine.runtime.set_watched(id, next.iter().cloned().collect()); + engine + .runtime + .set_watched(id, next.iter().cloned().collect()); // §2.4: *"keys are cleared when … a terminal leaves the watch set"*. Three of that // sentence's four events were implemented and this one was not. A `Command contains` rule @@ -1124,17 +1611,24 @@ pub fn targeting_tick( let Some(row) = by_id.get(tm.as_str()) else { continue; }; - let label = crate::automation::labels::label_at(&crate::automation::labels::LabelInputs { - display_label: row.display_label.as_deref(), - name: Some(row.name.as_str()), - shell: Some(row.shell.as_str()), - // Writing the snapshot, so the snapshot is not an input to it. - snapshot: None, - }); + let label = + crate::automation::labels::label_at(&crate::automation::labels::LabelInputs { + display_label: row.display_label.as_deref(), + name: Some(row.name.as_str()), + shell: Some(row.shell.as_str()), + // Writing the snapshot, so the snapshot is not an input to it. + snapshot: None, + }); if let Err(e) = - host.store().touch_target(id, tm, label.as_deref(), row.cwd.as_deref(), now_ms) + host.store() + .touch_target(id, tm, label.as_deref(), row.cwd.as_deref(), now_ms) { - log::warn!("automations: could not record {}'s view of {}: {}", id, tm, e); + log::warn!( + "automations: could not record {}'s view of {}: {}", + id, + tm, + e + ); } } @@ -1155,14 +1649,13 @@ pub fn targeting_tick( TargetingPass { missing, watched } } - #[cfg(test)] mod tests { use super::*; // The fake, the canonical rule and the wiring are shared with the dry run's tests so there can // only ever be one of each. - use crate::automation_engine::test_host::*; use crate::automation::roster::RosterRow; + use crate::automation_engine::test_host::*; use crate::automation_store::{AutomationRule, Finds, Keep}; use chrono::{Datelike, Local, NaiveDate, TimeZone, Weekday}; @@ -1186,17 +1679,26 @@ mod tests { // Twenty into a channel of four, before the tap has read any of them: the receiver is // guaranteed to see `Lagged`, which is the case that must not silently drop terminals. for i in 0..20 { - let _ = tx.send(ChannelPayload { id: format!("pc-{}", i % 2 + 1), data: vec![b'x'] }); + let _ = tx.send(ChannelPayload { + id: format!("pc-{}", i % 2 + 1), + data: vec![b'x'], + }); } let tap = tokio::spawn(run_tap(engine.clone(), host.clone(), rx)); tokio::time::sleep(Duration::from_millis(50)).await; assert!(engine.runtime.is_dirty("pc-1"), "pc-1 never marked"); - assert!(engine.runtime.is_dirty("pc-2"), "a lagged window must mark every live terminal"); + assert!( + engine.runtime.is_dirty("pc-2"), + "a lagged window must mark every live terminal" + ); engine.stop(); tokio::time::sleep(Duration::from_millis(BASE_TICK_MS * 3)).await; - assert!(tap.is_finished(), "the tap must return once `stopping` is set"); + assert!( + tap.is_finished(), + "the tap must return once `stopping` is set" + ); drop(tx); } @@ -1205,15 +1707,22 @@ mod tests { #[test] fn the_tap_body_never_reads_the_payload_bytes() { let source = strip_comments(include_str!("loops.rs")); - let start = source.find("pub async fn run_tap(").expect("run_tap must exist"); + let start = source + .find("pub async fn run_tap(") + .expect("run_tap must exist"); let rest = &source[start..]; - let end = rest.find("\n}\n").expect("its body must be closed at column zero"); + let end = rest + .find("\n}\n") + .expect("its body must be closed at column zero"); let body = &rest[..end]; assert!( !body.contains(".data"), "the tap carries a SIGNAL, not data: the parser already has every byte, losslessly" ); - assert!(body.contains("mark_dirty"), "and it must actually mark something"); + assert!( + body.contains("mark_dirty"), + "and it must actually mark something" + ); } // ============================================================================================= @@ -1235,13 +1744,19 @@ mod tests { let targeting = tokio::spawn(run_targeting(engine.clone(), host.clone())); // The tap does work. - let _ = tx.send(ChannelPayload { id: "pc-1".into(), data: vec![b'x'] }); + let _ = tx.send(ChannelPayload { + id: "pc-1".into(), + data: vec![b'x'], + }); tokio::time::sleep(Duration::from_millis(50)).await; assert!(engine.runtime.is_dirty("pc-1"), "the tap did nothing"); // The targeting tick does work: it re-resolves `All terminals` and adopts tm-1. tokio::time::sleep(Duration::from_millis(TARGETING_TICK_MS)).await; - assert!(engine.runtime.watches("au-1", "tm-1"), "the targeting tick did nothing"); + assert!( + engine.runtime.watches("au-1", "tm-1"), + "the targeting tick did nothing" + ); // And the evaluator does work: with a terminal watched and dirty, the pair evaluates. tokio::time::sleep(Duration::from_millis(BASE_TICK_MS * 4)).await; @@ -1273,12 +1788,21 @@ mod tests { let cursor = evaluate_tick(&engine, &host, 0, 1_000).await; assert_eq!(cursor, 0); - assert!(fake.written().is_empty(), "a first sight must arm, never type"); + assert!( + fake.written().is_empty(), + "a first sight must arm, never type" + ); assert_eq!(engine.runtime.arm_state("au-1", "tm-1"), ArmState::armed()); // Nothing in the log, and that is the verbose gate doing its job: an ordinary check is the // outcome of most evaluations and would otherwise write four rows a second per pair. - assert!(log_kinds(&fake.store).is_empty(), "an ungated check would flood the log"); - assert!(!engine.runtime.is_dirty("pc-1"), "the only pair on pc-1 ran, so its flag is spent"); + assert!( + log_kinds(&fake.store).is_empty(), + "an ungated check would flood the log" + ); + assert!( + !engine.runtime.is_dirty("pc-1"), + "the only pair on pc-1 ran, so its flag is spent" + ); // The crossing. `dirty` again, and past the 250 ms floor. engine.runtime.mark_dirty("pc-1"); @@ -1289,7 +1813,9 @@ mod tests { let writes = fake.written(); assert!( - writes.iter().any(|w| w.contains("prepare to do context-hand-off")), + writes + .iter() + .any(|w| w.contains("prepare to do context-hand-off")), "the message was never typed: {:?}", writes ); @@ -1308,10 +1834,14 @@ mod tests { // message had even been typed — so the next tick read the rule's own echo as organic output. let gap = crate::automation::send::PASTE_SUBMIT_GAP_MS as i64; assert!( - engine.runtime.is_settling("tm-1", 2_000 + ECHO_SETTLE_MS + 1), + engine + .runtime + .is_settling("tm-1", 2_000 + ECHO_SETTLE_MS + 1), "the window closed a paste-to-submit gap too early" ); - assert!(!engine.runtime.is_settling("tm-1", 2_000 + gap + ECHO_SETTLE_MS + 1)); + assert!(!engine + .runtime + .is_settling("tm-1", 2_000 + gap + ECHO_SETTLE_MS + 1)); } // ============================================================================================= @@ -1338,7 +1868,9 @@ mod tests { tokio::time::sleep(Duration::from_millis(1_500)).await; assert!( - fake.written().iter().any(|w| w.contains("Fix the 17 failing tests in a.ts")), + fake.written() + .iter() + .any(|w| w.contains("Fix the 17 failing tests in a.ts")), "the resolved message was never typed: {:?}", fake.written() ); @@ -1365,10 +1897,17 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_schedule_rule_reads_nothing_sends_nothing_and_logs_nothing() { let (engine, fake, host) = wire(vec![schedule_only_rule("au-sched")]); - assert_eq!(engine.snapshot_live().len(), 1, "premise: the rule IS live and IS walked"); + assert_eq!( + engine.snapshot_live().len(), + 1, + "premise: the rule IS live and IS walked" + ); - fake.say("pc-1", "ctx:99% FAILED 3 tests -"); + fake.say( + "pc-1", + "ctx:99% FAILED 3 tests +", + ); // **What `wire` already wrote, before the ticks run.** `wire` reloads at epoch 0 and §7's // seeding writes one `held` row for a schedule whose minute is already past *in the // runner's own zone* — 19:00 the previous evening west of UTC, midnight on it. So an @@ -1381,7 +1920,11 @@ mod tests { } tokio::time::sleep(Duration::from_millis(1_500)).await; - assert!(fake.written().is_empty(), "a rule with no pattern typed something: {:?}", fake.written()); + assert!( + fake.written().is_empty(), + "a rule with no pattern typed something: {:?}", + fake.written() + ); assert_eq!( log_rows(&fake.store), before, @@ -1406,7 +1949,7 @@ mod tests { /// is identical; only the pattern is present. If the rig itself were broken, this would be /// silent too, and "sends nothing" would prove nothing at all. #[tokio::test(start_paused = true)] - async fn the_same_rig_with_a_pattern_does_send() { + async fn the_same_rig_with_a_pattern_does_send() { let (engine, fake, host) = rig_with_rule(|g| { g.parse_mut().find = "FAILED".into(); g.parse_mut().keep = Keep::Whole; @@ -1415,8 +1958,11 @@ mod tests { }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); - fake.say("pc-1", "ctx:99% FAILED 3 tests -"); + fake.say( + "pc-1", + "ctx:99% FAILED 3 tests +", + ); for tick in 0..8 { engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 1_000 + tick * 250).await; @@ -1458,7 +2004,9 @@ mod tests { /// The local ordinal `at_local`'s day maps to — `last_fired_day`'s key. fn day_ordinal(y: i32, m: u32, d: u32) -> i32 { - NaiveDate::from_ymd_opt(y, m, d).expect("a real date").num_days_from_ce() + NaiveDate::from_ymd_opt(y, m, d) + .expect("a real date") + .num_days_from_ce() } /// A rig with several terminals and each rule's watched set given explicitly. @@ -1508,7 +2056,9 @@ mod tests { } for rule in &rules { if bypass_enable_gate { - fake.store.save_rule_bypassing_the_enable_gate_for_tests(rule).unwrap(); + fake.store + .save_rule_bypassing_the_enable_gate_for_tests(rule) + .unwrap(); } else { fake.store.save_rule(rule).unwrap(); } @@ -1516,7 +2066,9 @@ mod tests { let engine = Arc::new(AutomationEngine::new(0)); engine.reload(&fake.store, 0).unwrap(); for (id, leaves) in watched { - engine.runtime.set_watched(id, leaves.iter().map(|tm| tm.to_string()).collect()); + engine + .runtime + .set_watched(id, leaves.iter().map(|tm| tm.to_string()).collect()); } let host: Arc = fake.clone(); (engine, fake, host) @@ -1538,9 +2090,20 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_schedule_rule_sends_to_every_target_when_the_minute_arrives_and_reads_nothing() { let (engine, fake, host) = wire_targets( - vec![schedule_only_rule("au-sched"), ctx_rule_saying("au-read", "a reader", 2)], - &[("tm-1", "pc-1"), ("tm-2", "pc-2"), ("tm-3", "pc-3"), ("tm-4", "pc-4")], - &[("au-sched", &["tm-1", "tm-2", "tm-3"]), ("au-read", &["tm-4"])], + vec![ + schedule_only_rule("au-sched"), + ctx_rule_saying("au-read", "a reader", 2), + ], + &[ + ("tm-1", "pc-1"), + ("tm-2", "pc-2"), + ("tm-3", "pc-3"), + ("tm-4", "pc-4"), + ], + &[ + ("au-sched", &["tm-1", "tm-2", "tm-3"]), + ("au-read", &["tm-4"]), + ], ); // The reader has output and sits below its threshold, so it reads, arms, and sends nothing. fake.say("pc-4", "ctx:5%\n"); @@ -1570,8 +2133,16 @@ mod tests { // 6.3: a schedule rule has no arm state and must not disturb one. for tm in ["tm-1", "tm-2", "tm-3"] { - assert_eq!(engine.runtime.arm_state("au-sched", tm), ArmState::Unseen, "{tm} armed"); - assert_eq!(engine.runtime.last_eval("au-sched", tm), None, "{tm} was evaluated"); + assert_eq!( + engine.runtime.arm_state("au-sched", tm), + ArmState::Unseen, + "{tm} armed" + ); + assert_eq!( + engine.runtime.last_eval("au-sched", tm), + None, + "{tm} was evaluated" + ); } assert_eq!( engine.runtime.last_fired_day("au-sched"), @@ -1627,7 +2198,10 @@ mod tests { &[("tm-1", "pc-1"), ("tm-3", "pc-3")], &[("au-sched", &["tm-1", "tm-2", "tm-3"])], ); - assert!(host.process_for_leaf("tm-2").is_none(), "premise: tm-2 is dormant"); + assert!( + host.process_for_leaf("tm-2").is_none(), + "premise: tm-2 is dormant" + ); evaluate_tick(&engine, &host, 0, at_local(2026, 9, 7, Weekday::Mon, 9, 0)).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -1659,9 +2233,15 @@ mod tests { /// was killed outright. #[tokio::test(start_paused = true)] async fn a_schedule_whose_targets_were_all_asleep_does_not_nag_the_first_one_to_wake() { - let (engine, fake, host) = - wire_targets(vec![schedule_only_rule("au-sched")], &[], &[("au-sched", &["tm-1"])]); - assert!(host.process_for_leaf("tm-1").is_none(), "premise: nothing is awake"); + let (engine, fake, host) = wire_targets( + vec![schedule_only_rule("au-sched")], + &[], + &[("au-sched", &["tm-1"])], + ); + assert!( + host.process_for_leaf("tm-1").is_none(), + "premise: nothing is awake" + ); evaluate_tick(&engine, &host, 0, at_local(2026, 9, 7, Weekday::Mon, 9, 0)).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -1734,11 +2314,22 @@ mod tests { let tuesday_afternoon = at_local(2026, 9, 8, Weekday::Tue, 14, 0); evaluator_step(&engine, &host, 0, Some(woke_at), tuesday_afternoon).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert!(fake.written().is_empty(), "delivered later the same day: {:?}", fake.written()); + assert!( + fake.written().is_empty(), + "delivered later the same day: {:?}", + fake.written() + ); // Wednesday, with the app genuinely awake across the minute. let wednesday = at_local(2026, 9, 9, Weekday::Wed, 9, 0); - evaluator_step(&engine, &host, 0, Some(wednesday - BASE_TICK_MS as i64), wednesday).await; + evaluator_step( + &engine, + &host, + 0, + Some(wednesday - BASE_TICK_MS as i64), + wednesday, + ) + .await; tokio::time::sleep(Duration::from_millis(2_000)).await; assert_eq!( sent_to(&fake, "stand-up notes?"), @@ -1780,14 +2371,21 @@ mod tests { tokio::time::sleep(Duration::from_millis(2_000)).await; let rows: Vec<_> = log_rows(&fake.store).split_off(before); - assert_eq!(rows.len(), 1, "the wake spent Tuesday and said nothing: {rows:?}"); + assert_eq!( + rows.len(), + 1, + "the wake spent Tuesday and said nothing: {rows:?}" + ); assert_eq!(rows[0].0, "Held", "{rows:?}"); assert_eq!( rows[0].1, "09:00 went by while nothing was watching the clock, so today's run was skipped", "{rows:?}" ); - assert_eq!(rows[0].2, None, "a schedule's suppression names no terminal: {rows:?}"); + assert_eq!( + rows[0].2, None, + "a schedule's suppression names no terminal: {rows:?}" + ); assert!( fake.activity.load(std::sync::atomic::Ordering::Relaxed) > emits_before, "the row was written and no window was told to refetch the log" @@ -1825,7 +2423,12 @@ mod tests { evaluator_step(&engine, &host, 0, Some(nine - BASE_TICK_MS as i64), nine).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert_eq!(sent_to(&fake, "stand-up notes?"), vec!["pc-1"], "{:?}", fake.written()); + assert_eq!( + sent_to(&fake, "stand-up notes?"), + vec!["pc-1"], + "{:?}", + fake.written() + ); let rows: Vec<_> = log_rows(&fake.store).split_off(before); assert!( rows.iter().all(|(kind, _, _)| kind != "Held"), @@ -1961,14 +2564,19 @@ mod tests { async fn a_failed_schedule_send_rolls_back_to_what_was_there_and_still_names_the_terminal() { let mut hybrid = ctx_rule_saying("au-both", "stand-up notes?", 1); hybrid.graph.timer = Some(TimerStep { - mode: TimerMode::DailyAt { minute_of_day: 9 * 60, days: 0b0001_1111 }, + mode: TimerMode::DailyAt { + minute_of_day: 9 * 60, + days: 0b0001_1111, + }, }); let (engine, fake, host) = wire_targets_bypassing_the_enable_gate( vec![hybrid], &[("tm-1", "pc-1")], &[("au-both", &["tm-1"])], ); - engine.runtime.set_arm("au-both", "tm-1", ArmState::Fired { at_ms: 5 }); + engine + .runtime + .set_arm("au-both", "tm-1", ArmState::Fired { at_ms: 5 }); // `wire`'s own reload may have written §7's suppression row already, depending on the // runner's zone — see `a_schedule_rule_reads_nothing_sends_nothing_and_logs_nothing`. The // rows this test is about are the ones the tick adds. @@ -1979,7 +2587,11 @@ mod tests { fake.close("tm-1"); tokio::time::sleep(Duration::from_millis(2_000)).await; - assert!(fake.written().is_empty(), "{:?} reached a closed terminal", fake.written()); + assert!( + fake.written().is_empty(), + "{:?} reached a closed terminal", + fake.written() + ); assert_eq!( engine.runtime.arm_state("au-both", "tm-1"), ArmState::Fired { at_ms: 5 }, @@ -1989,7 +2601,9 @@ mod tests { assert_eq!(rows.len(), 1, "exactly one row: {rows:?}"); assert_eq!(rows[0].0, "Failed", "{rows:?}"); assert!( - rows[0].1.contains("the terminal closed before the message was sent"), + rows[0] + .1 + .contains("the terminal closed before the message was sent"), "{rows:?}" ); assert_eq!( @@ -2047,7 +2661,10 @@ mod tests { async fn a_schedule_rule_that_also_has_a_monitor_reads_nothing_and_fires_on_the_clock() { let mut hybrid = ctx_rule_saying("au-both", "stand-up notes?", 1); hybrid.graph.timer = Some(TimerStep { - mode: TimerMode::DailyAt { minute_of_day: 9 * 60, days: 0b0001_1111 }, + mode: TimerMode::DailyAt { + minute_of_day: 9 * 60, + days: 0b0001_1111, + }, }); let (engine, fake, host) = wire_targets_bypassing_the_enable_gate( vec![hybrid, ctx_rule_saying("au-read", "a reader", 2)], @@ -2068,7 +2685,11 @@ mod tests { "the monitor crossed on a rule the clock has not reached: {:?}", fake.written() ); - assert_eq!(fake.tailed(), vec!["pc-4"], "and the control read a window to prove it could"); + assert_eq!( + fake.tailed(), + vec!["pc-4"], + "and the control read a window to prove it could" + ); engine.runtime.mark_dirty("pc-1"); engine.runtime.mark_dirty("pc-4"); @@ -2111,7 +2732,9 @@ mod tests { tokio::time::sleep(Duration::from_millis(1_500)).await; assert!( - fake.written().iter().any(|w| w.contains("awk '{print $1}'")), + fake.written() + .iter() + .any(|w| w.contains("awk '{print $1}'")), "the literal message was never typed: {:?}", fake.written() ); @@ -2141,13 +2764,20 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(1_500)).await; - assert!(fake.written().is_empty(), "nothing may be typed: {:?}", fake.written()); + assert!( + fake.written().is_empty(), + "nothing may be typed: {:?}", + fake.written() + ); let log = log_details(&fake.store); assert!( log.iter().any(|(_, detail)| detail.contains("$3")), "the failure row must name the token, got: {log:?}" ); - assert!(log.iter().any(|(kind, _)| kind == "Failed"), "and it must be a Failed row: {log:?}"); + assert!( + log.iter().any(|(kind, _)| kind == "Failed"), + "and it must be a Failed row: {log:?}" + ); } // ============================================================================================= @@ -2171,7 +2801,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2187,7 +2819,11 @@ mod tests { evaluate_tick(&engine, &host, 0, 20_000).await; tokio::time::sleep(Duration::from_millis(1_500)).await; - assert!(fake.written().is_empty(), "still holding at 19s: {:?}", fake.written()); + assert!( + fake.written().is_empty(), + "still holding at 19s: {:?}", + fake.written() + ); evaluate_tick(&engine, &host, 0, 31_001).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -2213,7 +2849,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2252,7 +2890,12 @@ mod tests { // `due_at_ms` is the moment it may go, not the moment after. evaluate_tick(&engine, &host, 0, 31_000).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert_eq!(times_sent(&fake, "resume"), 1, "exactly one message: {:?}", fake.written()); + assert_eq!( + times_sent(&fake, "resume"), + 1, + "exactly one message: {:?}", + fake.written() + ); assert_eq!( engine.runtime.parked_at("au-1", "tm-1"), None, @@ -2273,7 +2916,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2322,7 +2967,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 300_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 300_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2331,7 +2978,11 @@ mod tests { // The crossing parks a send due at 301_000 (five minutes out). evaluator_step(&engine, &host, 0, None, 1_000).await; tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(engine.runtime.parked_at("au-1", "tm-1"), Some(301_000), "premise: it is parked"); + assert_eq!( + engine.runtime.parked_at("au-1", "tm-1"), + Some(301_000), + "premise: it is parked" + ); // A resume that drops nothing: over `RESUME_GAP_MS`, and the send is not yet even due. let quiet = fake.states.load(std::sync::atomic::Ordering::Relaxed); @@ -2373,7 +3024,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 300_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 300_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2393,7 +3046,11 @@ mod tests { Some(301_000), "a resume dropped a send that was not yet stale" ); - assert!(fake.written().is_empty(), "the send is not due yet: {:?}", fake.written()); + assert!( + fake.written().is_empty(), + "the send is not due yet: {:?}", + fake.written() + ); // An ordinary tick, once the wait is genuinely over, must still deliver it. evaluator_step(&engine, &host, 0, Some(120_000), 301_001).await; @@ -2418,7 +3075,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2469,13 +3128,18 @@ mod tests { g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume after $1".into(); g.action_mut().substitute = true; - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); fake.say("pc-1", "API error 529"); evaluate_tick(&engine, &host, 0, 1_000).await; - assert!(fake.written().is_empty(), "the premise: the crossing parked rather than sending"); + assert!( + fake.written().is_empty(), + "the premise: the crossing parked rather than sending" + ); // Half a minute of build output later, nothing of the match is left anywhere. fake.say("pc-1", "all clear\n"); @@ -2514,7 +3178,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2530,7 +3196,10 @@ mod tests { if restarted { // A spawn re-indexing a LIVE leaf, which is all `IdentityIndex::index` does. Not a // `forget_terminal`, because that is the path this hazard is NOT on. - fake.leaves.lock().unwrap().insert("tm-1".into(), "pc-2".into()); + fake.leaves + .lock() + .unwrap() + .insert("tm-1".into(), "pc-2".into()); } evaluate_tick(&engine, &host, 0, 31_001).await; @@ -2576,7 +3245,9 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); @@ -2585,11 +3256,18 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; // Guard against the vacuous version: a test that never parked anything would trivially type // nothing and stay green forever. - assert_eq!(engine.runtime.parked_at("au-1", "tm-1"), Some(31_000), "the premise: it parked"); + assert_eq!( + engine.runtime.parked_at("au-1", "tm-1"), + Some(31_000), + "the premise: it parked" + ); fake.store.set_enabled_checked("au-1", false).unwrap(); engine.reload(&fake.store, 2_000).unwrap(); - assert!(!engine.is_live("au-1"), "the premise: disabling drops it from the live set"); + assert!( + !engine.is_live("au-1"), + "the premise: disabling drops it from the live set" + ); evaluate_tick(&engine, &host, 0, 31_001).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -2608,18 +3286,30 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); fake.say("pc-1", "API error"); evaluate_tick(&engine, &host, 0, 1_000).await; - assert_eq!(engine.runtime.parked_at("au-1", "tm-1"), Some(31_000), "the premise: it parked"); + assert_eq!( + engine.runtime.parked_at("au-1", "tm-1"), + Some(31_000), + "the premise: it parked" + ); - assert!(fake.store.delete_rule("au-1").unwrap(), "the premise: the rule existed to delete"); + assert!( + fake.store.delete_rule("au-1").unwrap(), + "the premise: the rule existed to delete" + ); engine.reload(&fake.store, 2_000).unwrap(); - assert!(!engine.is_live("au-1"), "the premise: a deleted rule is not live"); + assert!( + !engine.is_live("au-1"), + "the premise: a deleted rule is not live" + ); evaluate_tick(&engine, &host, 0, 31_001).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -2639,17 +3329,26 @@ mod tests { g.parse_mut().find = "API error".into(); g.cond_mut().finds = Finds::Event; g.action_mut().message = "resume".into(); - g.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + g.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); }); engine.runtime.set_arm("au-1", "tm-1", ArmState::armed()); engine.runtime.mark_dirty("pc-1"); fake.say("pc-1", "API error"); evaluate_tick(&engine, &host, 0, 1_000).await; - assert_eq!(engine.runtime.parked_at("au-1", "tm-1"), Some(31_000), "the premise: it parked"); + assert_eq!( + engine.runtime.parked_at("au-1", "tm-1"), + Some(31_000), + "the premise: it parked" + ); - let mut edited = - fake.store.get_rule("au-1").unwrap().expect("the rule must still be in the store"); + let mut edited = fake + .store + .get_rule("au-1") + .unwrap() + .expect("the rule must still be in the store"); edited.updated_at = 2_000; fake.store.save_rule(&edited).unwrap(); engine.reload(&fake.store, 2_000).unwrap(); @@ -2662,7 +3361,9 @@ mod tests { // `TARGETING_TICK_MS` — that tick does not run in this harness. Re-establishing it here is // NOT the thing under test; skipping it would let the walk skip "tm-1" for a reason that has // nothing to do with §6.1, and the test would pass vacuously for the wrong reason. - engine.runtime.set_watched("au-1", ["tm-1".to_string()].into()); + engine + .runtime + .set_watched("au-1", ["tm-1".to_string()].into()); evaluate_tick(&engine, &host, 0, 31_001).await; tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -2684,10 +3385,14 @@ mod tests { once.runs_once = true; once.graph.parse_mut().find = "API error".into(); once.graph.cond_mut().finds = Finds::Event; - once.graph.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + once.graph.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); let (engine, fake, host) = wire(vec![once]); open_second_terminal(&fake); - engine.runtime.set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); + engine + .runtime + .set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); for tm in ["tm-1", "tm-2"] { engine.runtime.set_arm("au-once", tm, ArmState::armed()); } @@ -2699,7 +3404,11 @@ mod tests { engine.runtime.mark_dirty("pc-2"); evaluate_tick(&engine, &host, 0, 1_250).await; assert_eq!(engine.runtime.parked_at("au-once", "tm-1"), Some(31_000)); - assert_eq!(engine.runtime.parked_at("au-once", "tm-2"), Some(31_250), "both parked"); + assert_eq!( + engine.runtime.parked_at("au-once", "tm-2"), + Some(31_250), + "both parked" + ); evaluate_tick(&engine, &host, 0, 31_250).await; tokio::time::sleep(Duration::from_millis(4_000)).await; @@ -2735,10 +3444,14 @@ mod tests { once.runs_once = true; once.graph.parse_mut().find = "API error".into(); once.graph.cond_mut().finds = Finds::Event; - once.graph.timer = Some(TimerStep { mode: TimerMode::AfterMatch { delay_ms: 30_000 } }); + once.graph.timer = Some(TimerStep { + mode: TimerMode::AfterMatch { delay_ms: 30_000 }, + }); let (engine, fake, host) = wire(vec![once]); open_second_terminal(&fake); - engine.runtime.set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); + engine + .runtime + .set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); for tm in ["tm-1", "tm-2"] { engine.runtime.set_arm("au-once", tm, ArmState::armed()); } @@ -2750,14 +3463,27 @@ mod tests { engine.runtime.mark_dirty("pc-2"); evaluate_tick(&engine, &host, 0, 1_250).await; assert_eq!(engine.runtime.parked_at("au-once", "tm-1"), Some(31_000)); - assert_eq!(engine.runtime.parked_at("au-once", "tm-2"), Some(31_250), "premise: both parked"); + assert_eq!( + engine.runtime.parked_at("au-once", "tm-2"), + Some(31_250), + "premise: both parked" + ); evaluate_tick(&engine, &host, 0, 31_250).await; tokio::time::sleep(Duration::from_millis(4_000)).await; let sent = sent_to(&fake, "once only"); - assert_eq!(sent.len(), 1, "premise: R6 still lets exactly one through: {:?}", fake.written()); - let loser = if sent[0] == "pc-1" { "second" } else { "codex · core" }; + assert_eq!( + sent.len(), + 1, + "premise: R6 still lets exactly one through: {:?}", + fake.written() + ); + let loser = if sent[0] == "pc-1" { + "second" + } else { + "codex · core" + }; let dropped: Vec<_> = log_rows(&fake.store) .into_iter() @@ -2790,14 +3516,19 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_dormant_terminal_produces_no_evaluation_and_no_log_line() { let (engine, fake, host) = wired(); - engine.runtime.set_arm("au-1", "tm-1", ArmState::Fired { at_ms: 5 }); + engine + .runtime + .set_arm("au-1", "tm-1", ArmState::Fired { at_ms: 5 }); engine.runtime.mark_dirty("pc-1"); // Watched, but the leaf resolves to nothing: session restore has not re-registered it. fake.close("tm-1"); evaluate_tick(&engine, &host, 0, 1_000).await; - assert_eq!(engine.runtime.arm_state("au-1", "tm-1"), ArmState::Fired { at_ms: 5 }); + assert_eq!( + engine.runtime.arm_state("au-1", "tm-1"), + ArmState::Fired { at_ms: 5 } + ); assert_eq!(engine.runtime.last_eval("au-1", "tm-1"), None); assert!(log_kinds(&fake.store).is_empty()); } @@ -2812,7 +3543,11 @@ mod tests { engine.runtime.settle_until("tm-1", 5_000); evaluate_tick(&engine, &host, 0, 1_000).await; - assert_eq!(engine.runtime.last_eval("au-1", "tm-1"), None, "settling means untouched"); + assert_eq!( + engine.runtime.last_eval("au-1", "tm-1"), + None, + "settling means untouched" + ); // And once the window closes it evaluates normally again. evaluate_tick(&engine, &host, 0, 5_001).await; @@ -2838,9 +3573,15 @@ mod tests { .into_iter() .find(|l| l.rule.id == rule_id) .expect("the rule must be live for a send to have been decided"); - engine.runtime.set_arm(rule_id, "tm-1", ArmState::Fired { at_ms }); + engine + .runtime + .set_arm(rule_id, "tm-1", ArmState::Fired { at_ms }); PendingSend { - pair: Pair { rule, tm: "tm-1".into(), pc: "pc-1".into() }, + pair: Pair { + rule, + tm: "tm-1".into(), + pc: "pc-1".into(), + }, prev, label: host.label_for("tm-1"), at_ms, @@ -2889,7 +3630,12 @@ mod tests { tokio::time::sleep(Duration::from_millis(2_000)).await; let writes = fake.written(); - assert_eq!(writes.len(), 6, "two sends of three writes each: {:?}", writes); + assert_eq!( + writes.len(), + 6, + "two sends of three writes each: {:?}", + writes + ); assert_eq!( paste_positions(&writes), vec![0, 3], @@ -2902,11 +3648,20 @@ mod tests { for i in [0usize, 3] { let alpha = writes[i].contains("alpha speaking"); let bravo = writes[i].contains("bravo speaking"); - assert_ne!(alpha, bravo, "one paste carried both messages: {:?}", writes); + assert_ne!( + alpha, bravo, + "one paste carried both messages: {:?}", + writes + ); carried.push(if alpha { "alpha" } else { "bravo" }); } carried.sort_unstable(); - assert_eq!(carried, vec!["alpha", "bravo"], "both rules must have sent: {:?}", writes); + assert_eq!( + carried, + vec!["alpha", "bravo"], + "both rules must have sent: {:?}", + writes + ); } /// A send that cannot take the terminal's queue within [`SEND_QUEUE_TIMEOUT_MS`] gives up: one @@ -2923,9 +3678,12 @@ mod tests { let _held = lock.lock().await; let send = pending(&engine, &host, "au-1", ArmState::re_armed(), 1_000); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; - assert!(fake.written().is_empty(), "a send that never got the queue must type nothing"); + assert!( + fake.written().is_empty(), + "a send that never got the queue must type nothing" + ); assert_eq!( engine.runtime.arm_state("au-1", "tm-1"), ArmState::re_armed(), @@ -2934,8 +3692,16 @@ mod tests { let rows = log_details(&fake.store); assert_eq!(rows.len(), 1, "exactly one row: {:?}", rows); assert_eq!(rows[0].0, "Failed"); - assert!(rows[0].1.contains("another rule was still sending"), "{:?}", rows); - assert_eq!(engine.runtime.fire_record("au-1", "tm-1"), None, "a failed send never fired"); + assert!( + rows[0].1.contains("another rule was still sending"), + "{:?}", + rows + ); + assert_eq!( + engine.runtime.fire_record("au-1", "tm-1"), + None, + "a failed send never fired" + ); } // ============================================================================================= @@ -2974,7 +3740,7 @@ mod tests { let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); break_it(&fake); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; tokio::time::sleep(Duration::from_millis(1_500)).await; assert_eq!( @@ -2984,7 +3750,12 @@ mod tests { what, fake.written() ); - assert_eq!(engine.runtime.arm_state("au-1", "tm-1"), ArmState::armed(), "{}", what); + assert_eq!( + engine.runtime.arm_state("au-1", "tm-1"), + ArmState::armed(), + "{}", + what + ); let rows = log_details(&fake.store); assert_eq!(rows.len(), 1, "{}: exactly one row, got {:?}", what, rows); assert_eq!(rows[0].0, "Failed", "{}: {:?}", what, rows); @@ -3024,8 +3795,15 @@ mod tests { bad.graph.parse_mut().find = r"ctx:(\d+%".into(); let (engine, fake, host) = wire(vec![bad]); - assert_eq!(log_kinds(&fake.store), vec!["Failed".to_string()], "one row, written at load"); - assert!(engine.snapshot_live().is_empty(), "and the rule is not running"); + assert_eq!( + log_kinds(&fake.store), + vec!["Failed".to_string()], + "one row, written at load" + ); + assert!( + engine.snapshot_live().is_empty(), + "and the rule is not running" + ); fake.say("pc-1", "ctx:63%\n"); let mut cursor = 0; @@ -3034,8 +3812,15 @@ mod tests { cursor = evaluate_tick(&engine, &host, cursor, t * 1_000).await; } - assert_eq!(log_kinds(&fake.store), vec!["Failed".to_string()], "a tick wrote a second row"); - assert!(fake.written().is_empty(), "and an uncompilable rule must never send"); + assert_eq!( + log_kinds(&fake.store), + vec!["Failed".to_string()], + "a tick wrote a second row" + ); + assert!( + fake.written().is_empty(), + "and an uncompilable rule must never send" + ); } // ============================================================================================= @@ -3050,9 +3835,12 @@ mod tests { let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); engine.stop(); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; - assert!(fake.written().is_empty(), "a send that started after the quit must type nothing"); + assert!( + fake.written().is_empty(), + "a send that started after the quit must type nothing" + ); assert_eq!( engine.runtime.arm_state("au-1", "tm-1"), ArmState::armed(), @@ -3064,7 +3852,11 @@ mod tests { // the arm state is restored, the crossing is still armed, and it fires on the next launch. // A row here would be the only trace of a non-event, in a 200-row log §3.3 reserves for // decisions a user can act on. - assert!(log_kinds(&fake.store).is_empty(), "{:?}", log_details(&fake.store)); + assert!( + log_kinds(&fake.store).is_empty(), + "{:?}", + log_details(&fake.store) + ); } // Already typing: the flag is set during the paste-to-submit gap and the submit still goes @@ -3072,10 +3864,14 @@ mod tests { { let (engine, fake, host) = wired(); let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); - let task = tokio::spawn(run_send(engine.clone(), host.clone(), send)); + let task = tokio::spawn(run_crossing(engine.clone(), host.clone(), send)); tokio::time::sleep(Duration::from_millis(100)).await; - assert_eq!(fake.written().len(), 1, "the paste must be out and the gap running"); + assert_eq!( + fake.written().len(), + 1, + "the paste must be out and the gap running" + ); engine.stop(); tokio::time::sleep(Duration::from_millis(1_000)).await; assert!(task.is_finished(), "the send must not park on the flag"); @@ -3112,8 +3908,16 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(1_500)).await; - assert_eq!(fake.written().len(), 3, "the crossing must send: {:?}", fake.written()); - assert_eq!(engine.runtime.echoes_for("tm-1", 1_000), vec!["HANDOFF now".to_string()]); + assert_eq!( + fake.written().len(), + 3, + "the crossing must send: {:?}", + fake.written() + ); + assert_eq!( + engine.runtime.echoes_for("tm-1", 1_000), + vec!["HANDOFF now".to_string()] + ); // The real line has scrolled off. All that is left is what this rule typed. fake.say("pc-1", "HANDOFF now\n"); @@ -3221,7 +4025,10 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert!(!engine.is_live("au-once"), "§7.8: completion is an in-memory event FIRST"); + assert!( + !engine.is_live("au-once"), + "§7.8: completion is an in-memory event FIRST" + ); assert_eq!( engine.runtime.arm_state("au-once", "tm-1"), ArmState::Unseen, @@ -3254,7 +4061,11 @@ mod tests { tokio::time::sleep(Duration::from_millis(2_000)).await; } - assert_eq!(times_sent(&fake, "once only"), 1, "a runs-once rule fired twice in one session"); + assert_eq!( + times_sent(&fake, "once only"), + 1, + "a runs-once rule fired twice in one session" + ); assert_eq!( times_sent(&fake, "every time"), 2, @@ -3264,16 +4075,27 @@ mod tests { // (b) The next launch: a fresh engine loads the same store and must not run it at all. let next = Arc::new(AutomationEngine::new(0)); next.reload(&fake.store, 6_000).unwrap(); - assert!(!next.is_live("au-once"), "the reload filter is the second line of defence"); - assert!(next.is_live("au-many"), "and only the completed rule is filtered"); + assert!( + !next.is_live("au-once"), + "the reload filter is the second line of defence" + ); + assert!( + next.is_live("au-many"), + "and only the completed rule is filtered" + ); - next.runtime.set_watched("au-once", ["tm-1".to_string()].into()); + next.runtime + .set_watched("au-once", ["tm-1".to_string()].into()); next.runtime.set_arm("au-once", "tm-1", ArmState::armed()); fake.say("pc-1", "ctx:77%\n"); next.runtime.mark_dirty("pc-1"); evaluate_tick(&next, &host, 0, 7_000).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert_eq!(times_sent(&fake, "once only"), 1, "a completed rule ran after a reload"); + assert_eq!( + times_sent(&fake, "once only"), + 1, + "a completed rule ran after a reload" + ); } /// **The completion nobody was told about.** @@ -3301,14 +4123,24 @@ mod tests { engine.runtime.set_arm(id, "tm-1", ArmState::armed()); } - fake.say("pc-1", "ctx:63% -"); + fake.say( + "pc-1", "ctx:63% +", + ); engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert_eq!(times_sent(&fake, "once only"), 1, "the runs-once rule never fired"); - assert_eq!(times_sent(&fake, "every time"), 1, "the control never fired"); + assert_eq!( + times_sent(&fake, "once only"), + 1, + "the runs-once rule never fired" + ); + assert_eq!( + times_sent(&fake, "every time"), + 1, + "the control never fired" + ); assert_eq!( fake.announced(), vec!["au-once".to_string()], @@ -3353,7 +4185,11 @@ mod tests { !engine.is_live("au-once"), "a failed stamp took the in-memory retirement with it, so the rule can fire again now" ); - assert_eq!(fake.announced(), vec!["au-once".to_string()], "the windows were not told"); + assert_eq!( + fake.announced(), + vec!["au-once".to_string()], + "the windows were not told" + ); let log = log_details(&fake.store); assert!( log.iter().any(|(kind, detail)| kind == "Failed" @@ -3387,7 +4223,10 @@ mod tests { // exactly that window. evaluate_tick(&engine, &host, 0, 1_000).await; if restarted { - fake.leaves.lock().unwrap().insert("tm-1".into(), "pc-2".into()); + fake.leaves + .lock() + .unwrap() + .insert("tm-1".into(), "pc-2".into()); } tokio::time::sleep(Duration::from_millis(2_000)).await; @@ -3417,17 +4256,30 @@ mod tests { // Before any tick, and inside the grace window, nothing is reported missing — §4.5: at t=0 the // live set is empty and session restore has not run. - assert!(!is_missing(&engine, "au-1", "tm-gone"), "reported before the grace elapsed"); + assert!( + !is_missing(&engine, "au-1", "tm-gone"), + "reported before the grace elapsed" + ); targeting_tick(&engine, &host, 1_000); - assert!(!is_missing(&engine, "au-1", "tm-gone"), "the grace window did not hold"); + assert!( + !is_missing(&engine, "au-1", "tm-gone"), + "the grace window did not hold" + ); // Past the grace, the pinned id is not in the roster and is reported — through the payload // first paint actually calls, not through the tick's return value. targeting_tick(&engine, &host, 120_000); - assert!(is_missing(&engine, "au-1", "tm-gone"), "first paint cannot see what the tick found"); + assert!( + is_missing(&engine, "au-1", "tm-gone"), + "first paint cannot see what the tick found" + ); // And it is retracted the moment the terminal comes back, rather than latching. - _fake.leaves.lock().unwrap().insert("tm-gone".into(), "pc-9".into()); + _fake + .leaves + .lock() + .unwrap() + .insert("tm-gone".into(), "pc-9".into()); _fake.roster.lock().unwrap().push(RosterRow { terminal_id: Some("tm-gone".into()), process_id: "pc-9".into(), @@ -3439,7 +4291,10 @@ mod tests { command_lines: Vec::new(), }); targeting_tick(&engine, &host, 130_000); - assert!(!is_missing(&engine, "au-1", "tm-gone"), "dormant, never dead — it came back"); + assert!( + !is_missing(&engine, "au-1", "tm-gone"), + "dormant, never dead — it came back" + ); } fn is_missing(engine: &Arc, rule_id: &str, tm: &str) -> bool { @@ -3477,7 +4332,11 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(2_000)).await; - assert_eq!(times_sent(&fake, "bravo speaking"), 1, "the premise: B has fired once"); + assert_eq!( + times_sent(&fake, "bravo speaking"), + 1, + "the premise: B has fired once" + ); // The user flips A off. Nothing about B changed. let mut off = ctx_rule_saying("au-a", "alpha speaking", 1); @@ -3507,7 +4366,10 @@ mod tests { "the decision must be `held`, and be visible as one: {:?}", log_kinds(&fake.store) ); - assert!(!engine.is_live("au-a"), "the premise: A really did leave the live set"); + assert!( + !engine.is_live("au-a"), + "the premise: A really did leave the live set" + ); } // ============================================================================================= @@ -3524,7 +4386,7 @@ mod tests { // The decision has been made and carried; NOW the terminal goes away completely. fake.close("tm-1"); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; let rows = log_rows(&fake.store); assert_eq!(rows.len(), 1, "{:?}", rows); @@ -3542,7 +4404,7 @@ mod tests { let (engine, fake, host) = wired(); let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; tokio::time::sleep(Duration::from_millis(1_500)).await; let rows = log_rows(&fake.store); @@ -3560,7 +4422,9 @@ mod tests { async fn a_row_that_sent_nothing_carries_the_name_as_well() { let (engine, fake, host) = wired(); // Fired and still true: `held`, which is a Decision-class row and so not verbose-gated. - engine.runtime.set_arm("au-1", "tm-1", ArmState::Fired { at_ms: 500 }); + engine + .runtime + .set_arm("au-1", "tm-1", ArmState::Fired { at_ms: 500 }); fake.say("pc-1", "ctx:63%\n"); engine.runtime.mark_dirty("pc-1"); @@ -3568,7 +4432,11 @@ mod tests { let rows = log_rows(&fake.store); assert_eq!(rows.len(), 1, "{:?}", rows); - assert_ne!(rows[0].0, "Sent", "the premise: nothing was sent — {:?}", rows); + assert_ne!( + rows[0].0, "Sent", + "the premise: nothing was sent — {:?}", + rows + ); assert_eq!(rows[0].2.as_deref(), Some("codex · core"), "{:?}", rows); assert!(fake.written().is_empty()); } @@ -3593,12 +4461,17 @@ mod tests { cwd: None, command_lines: Vec::new(), }); - fake.leaves.lock().unwrap().insert("tm-2".into(), "pc-2".into()); + fake.leaves + .lock() + .unwrap() + .insert("tm-2".into(), "pc-2".into()); // Both terminals match `All terminals`, and both have fired. targeting_tick(&engine, &host, 1_000); for tm in ["tm-1", "tm-2"] { - engine.runtime.set_arm("au-1", tm, ArmState::Fired { at_ms: 500 }); + engine + .runtime + .set_arm("au-1", tm, ArmState::Fired { at_ms: 500 }); engine.runtime.set_last_eval("au-1", tm, 500); engine.runtime.record_fire("au-1", tm, 500); } @@ -3607,7 +4480,11 @@ mod tests { fake.close("tm-2"); targeting_tick(&engine, &host, 3_000); - assert_eq!(engine.runtime.arm_state("au-1", "tm-2"), ArmState::Unseen, "the stale key survived"); + assert_eq!( + engine.runtime.arm_state("au-1", "tm-2"), + ArmState::Unseen, + "the stale key survived" + ); assert_eq!(engine.runtime.last_eval("au-1", "tm-2"), None); assert_eq!( engine.runtime.fire_record("au-1", "tm-2"), @@ -3643,7 +4520,7 @@ mod tests { engine.stop(); } - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; assert_eq!( engine.runtime.arm_state("au-1", "tm-1"), @@ -3667,7 +4544,7 @@ mod tests { *fake.write_err.lock().unwrap() = Some("no writer".into()); } - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; assert_eq!( engine.runtime.arm_state("au-1", "tm-1"), @@ -3693,23 +4570,34 @@ mod tests { // mutation to `tokio::spawn` survived it. `ends_with` can only be satisfied by the characters // immediately before the call — by the call itself. let engine = strip_comments(include_str!("../automation_engine.rs")); - let start = engine.find("pub fn spawn").expect("spawn must exist"); + let start = engine + .find("pub fn spawn") + .expect("spawn must exist"); let body = &engine[start..]; let outer = body.find("spawn({").expect("it must spawn something"); assert!( body[..outer].ends_with("tauri::async_runtime::"), "the OUTER spawn runs from `.setup()`, where no runtime is entered, so it must go \ through Tauri's wrapper; the call reads `{}spawn({{`", - body[..outer].rsplit('\n').next().unwrap_or_default().trim_start() + body[..outer] + .rsplit('\n') + .next() + .unwrap_or_default() + .trim_start() ); let lib = strip_comments(include_str!("../lib.rs")); - let setup_start = - lib.find("spawn_history_flush_task(state.clone());").expect("the setup site"); + let setup_start = lib + .find("spawn_history_flush_task(state.clone());") + .expect("the setup site"); // Windowed by LINES rather than bytes: `strip_comments` drops comment-only lines and leaves // trailing ones, so a multi-byte character landing inside a fixed byte window panics with a // slice error instead of failing with this test's own message. - let setup = lib[setup_start..].lines().take(12).collect::>().join("\n"); + let setup = lib[setup_start..] + .lines() + .take(12) + .collect::>() + .join("\n"); let setup = setup.as_str(); assert!( !setup.contains("tokio::spawn"), @@ -3737,7 +4625,9 @@ mod tests { once.runs_once = true; let (engine, fake, host) = wire(vec![once]); open_second_terminal(&fake); - engine.runtime.set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); + engine + .runtime + .set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); for tm in ["tm-1", "tm-2"] { engine.runtime.set_arm("au-once", tm, ArmState::armed()); } @@ -3756,7 +4646,10 @@ mod tests { fake.written() ); assert_eq!( - log_rows(&fake.store).iter().filter(|(k, _, _)| k == "Sent").count(), + log_rows(&fake.store) + .iter() + .filter(|(k, _, _)| k == "Sent") + .count(), 1, "and it logged every one of them" ); @@ -3779,7 +4672,9 @@ mod tests { once.runs_once = true; let (engine, fake, host) = wire(vec![once]); open_second_terminal(&fake); - engine.runtime.set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); + engine + .runtime + .set_watched("au-once", ["tm-1".to_string(), "tm-2".to_string()].into()); for tm in ["tm-1", "tm-2"] { engine.runtime.set_arm("au-once", tm, ArmState::armed()); } @@ -3805,7 +4700,10 @@ mod tests { fake.written() ); assert_eq!( - log_rows(&fake.store).iter().filter(|(k, _, _)| k == "Sent").count(), + log_rows(&fake.store) + .iter() + .filter(|(k, _, _)| k == "Sent") + .count(), 1, "and logged both of them" ); @@ -3823,7 +4721,9 @@ mod tests { let mut once = ctx_rule_saying("au-once", "once only", 1); once.runs_once = true; let (engine, fake, host) = wire(vec![once]); - engine.runtime.set_watched("au-once", ["tm-1".to_string()].into()); + engine + .runtime + .set_watched("au-once", ["tm-1".to_string()].into()); engine.runtime.set_arm("au-once", "tm-1", ArmState::armed()); *fake.write_err.lock().unwrap() = Some("no writer".into()); @@ -3836,10 +4736,16 @@ mod tests { // so `written()` shows the attempt either way. A `Sent` row is only written after `deliver` // returns `Ok`. let sent_rows = |f: &FakeHost| { - log_rows(&f.store).iter().filter(|(k, _, _)| k == "Sent").count() + log_rows(&f.store) + .iter() + .filter(|(k, _, _)| k == "Sent") + .count() }; assert_eq!(sent_rows(&fake), 0, "the premise: the write was refused"); - assert!(engine.is_live("au-once"), "a failed send must not complete the rule"); + assert!( + engine.is_live("au-once"), + "a failed send must not complete the rule" + ); assert_eq!( engine.runtime.arm_state("au-once", "tm-1"), ArmState::armed(), @@ -3883,12 +4789,18 @@ mod tests { "the premise: nothing was evaluated, so no arm state moved" ); assert!(log_rows(&fake.store).is_empty(), "§4.5: no read, no row"); - assert!(engine.runtime.is_dirty("pc-1"), "the tick spent a signal no pair could read"); + assert!( + engine.runtime.is_dirty("pc-1"), + "the tick spent a signal no pair could read" + ); // The paired positive, so this is not satisfied by never clearing anything. fake.say("pc-1", "ctx:18%\n"); evaluate_tick(&engine, &host, 0, 2_000).await; - assert!(!engine.runtime.is_dirty("pc-1"), "an ordinary read must still spend the signal"); + assert!( + !engine.runtime.is_dirty("pc-1"), + "an ordinary read must still spend the signal" + ); } /// The other half of B-2's fix, and it had no oracle: **only a `runs_once` rule is deduped.** @@ -3902,7 +4814,9 @@ mod tests { async fn a_repeating_rule_still_sends_to_every_terminal_it_watches() { let (engine, fake, host) = wire(vec![ctx_rule_saying("au-many", "every one", 1)]); open_second_terminal(&fake); - engine.runtime.set_watched("au-many", ["tm-1".to_string(), "tm-2".to_string()].into()); + engine + .runtime + .set_watched("au-many", ["tm-1".to_string(), "tm-2".to_string()].into()); for tm in ["tm-1", "tm-2"] { engine.runtime.set_arm("au-many", tm, ArmState::armed()); } @@ -3938,7 +4852,10 @@ mod tests { // The first pass adopts tm-1 and says so. Drained here, so what is asserted below is the // SECOND change and not this one. tokio::time::sleep(Duration::from_millis(TARGETING_TICK_MS / 2)).await; - assert!(engine.take_state_emit(1_000), "the first pass must announce the rule's first leaf"); + assert!( + engine.take_state_emit(1_000), + "the first pass must announce the rule's first leaf" + ); assert!(engine.runtime.watches("au-1", "tm-1")); // A second terminal opens. `All terminals` + `follow_new` adopts it. @@ -3946,7 +4863,10 @@ mod tests { tokio::time::sleep(Duration::from_millis(TARGETING_TICK_MS * 2)).await; engine.stop(); - assert!(engine.runtime.watches("au-1", "tm-2"), "the premise: it was adopted"); + assert!( + engine.runtime.watches("au-1", "tm-2"), + "the premise: it was adopted" + ); assert!( engine.take_state_emit(10_000), "the watch set grew and no window was told; only `missing` was being diffed" @@ -3960,7 +4880,10 @@ mod tests { let (engine, _fake, host) = wired(); let quiet = tokio::spawn(run_targeting(engine.clone(), host.clone())); tokio::time::sleep(Duration::from_millis(TARGETING_TICK_MS / 2)).await; - assert!(engine.take_state_emit(1_000), "the premise: the first pass adopted tm-1"); + assert!( + engine.take_state_emit(1_000), + "the premise: the first pass adopted tm-1" + ); tokio::time::sleep(Duration::from_millis(TARGETING_TICK_MS * 3)).await; engine.stop(); assert!( @@ -4001,7 +4924,11 @@ mod tests { Some(1_000), "the premise: A was due and ran" ); - assert_eq!(engine.runtime.last_eval("au-b", "tm-1"), Some(900), "and B did not"); + assert_eq!( + engine.runtime.last_eval("au-b", "tm-1"), + Some(900), + "and B did not" + ); assert!( engine.runtime.is_dirty("pc-1"), "A spent the flag on B's behalf, and B never sees this output again" @@ -4012,7 +4939,10 @@ mod tests { // for A instead; that is the rule working, not a second bug.) evaluate_tick(&engine, &host, 0, 1_300).await; assert_eq!(engine.runtime.last_eval("au-b", "tm-1"), Some(1_300)); - assert!(!engine.runtime.is_dirty("pc-1"), "and now the flag is genuinely spent"); + assert!( + !engine.runtime.is_dirty("pc-1"), + "and now the flag is genuinely spent" + ); } /// **M-5: `touch_target` had no production caller**, so `automation_targets` held rows only for @@ -4031,8 +4961,14 @@ mod tests { targeting_tick(&engine, &host, 1_000); let rows = fake.store.targets_for("au-1").unwrap(); - let row = rows.iter().find(|r| r.0 == "tm-1").unwrap_or_else(|| panic!("{:?}", rows)); - assert_eq!(row.1, "matched", "a criterion match is never pinned, so nothing else writes it"); + let row = rows + .iter() + .find(|r| r.0 == "tm-1") + .unwrap_or_else(|| panic!("{:?}", rows)); + assert_eq!( + row.1, "matched", + "a criterion match is never pinned, so nothing else writes it" + ); assert_eq!(row.2.as_deref(), Some("codex · core")); assert_eq!(row.3.as_deref(), Some("D:/sources/work")); } @@ -4045,12 +4981,22 @@ mod tests { #[test] fn the_exit_arm_stops_the_engine_before_anything_slow() { let lib = strip_comments(include_str!("../lib.rs")); - let start = lib.find("if let RunEvent::Exit = event {").expect("the Exit arm"); + let start = lib + .find("if let RunEvent::Exit = event {") + .expect("the Exit arm"); let arm = lib[start..].lines().take(25).collect::>().join("\n"); let arm = arm.as_str(); - let stop = arm.find("automations.stop()").expect("Exit must stop the engine"); - for slow in ["flush_all_history(", "shutdown_mcp_server(", "shutdown_fabric("] { - let at = arm.find(slow).unwrap_or_else(|| panic!("{} left the Exit arm", slow)); + let stop = arm + .find("automations.stop()") + .expect("Exit must stop the engine"); + for slow in [ + "flush_all_history(", + "shutdown_mcp_server(", + "shutdown_fabric(", + ] { + let at = arm + .find(slow) + .unwrap_or_else(|| panic!("{} left the Exit arm", slow)); assert!(stop < at, "the loops keep deciding across {}", slow); } } @@ -4086,7 +5032,10 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; - assert!(lookups.load(Ordering::Relaxed) >= 2, "the premise: both pairs resolved their leaf"); + assert!( + lookups.load(Ordering::Relaxed) >= 2, + "the premise: both pairs resolved their leaf" + ); assert!( engine.runtime.is_dirty("pc-1"), "the tick cleared a signal that arrived after it had finished reading" @@ -4106,8 +5055,13 @@ mod tests { targeting_tick(&engine, &host, 1_000); - let ids: Vec = - fake.store.targets_for("au-1").unwrap().into_iter().map(|r| r.0).collect(); + let ids: Vec = fake + .store + .targets_for("au-1") + .unwrap() + .into_iter() + .map(|r| r.0) + .collect(); assert_eq!( ids, vec!["tm-1".to_string()], @@ -4134,7 +5088,8 @@ mod tests { let pass = targeting_tick(&engine, &host, 1_000); assert!( - fake.last_roster_criteria().contains(&Criterion::CommandContains), + fake.last_roster_criteria() + .contains(&Criterion::CommandContains), "the exclusion must request the process scan that populates command lines" ); assert_eq!( @@ -4172,14 +5127,24 @@ mod tests { // inside the very loop that fills `skipped` — so this row pins THIS FUNCTION's contract, that // `emit` is the gate and the list is not consulted when it is closed, rather than a state the // engine reaches. - assert_eq!(refusals_to_announce(&ReloadReport { emit: false, ..refused }), None); + assert_eq!( + refusals_to_announce(&ReloadReport { + emit: false, + ..refused + }), + None + ); // An empty `skipped` with `emit` set: also unreachable from `reload`, and also this function's // contract — an empty list is not `None`, because a caller with a reason to emit and no ids to // name still emits. *(This comment used to justify the row with "a row written by something // else in the same load"; `reload` has no such mechanism.)* assert_eq!( - refusals_to_announce(&ReloadReport { live: 3, skipped: vec![], emit: true }), + refusals_to_announce(&ReloadReport { + live: 3, + skipped: vec![], + emit: true + }), Some(vec![]) ); } @@ -4199,8 +5164,10 @@ mod tests { engine.runtime.set_arm(id, "tm-1", ArmState::armed()); } engine.runtime.set_last_eval("au-timer", "tm-1", 900); - fake.say("pc-1", "ctx:18% -"); + fake.say( + "pc-1", "ctx:18% +", + ); engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 1_000).await; @@ -4244,7 +5211,11 @@ mod tests { evaluate_tick(&engine, &host, 0, 1_000).await; tokio::time::sleep(Duration::from_millis(4_000)).await; - assert_eq!(times_sent(&fake, "first message"), 1, "the premise: both sent"); + assert_eq!( + times_sent(&fake, "first message"), + 1, + "the premise: both sent" + ); assert_eq!(times_sent(&fake, "second message"), 1); // The first send lands one paste-to-submit gap after the decision; the second waits for the @@ -4252,10 +5223,14 @@ mod tests { let gap = crate::automation::send::PASTE_SUBMIT_GAP_MS as i64; let second_landed = 1_000 + gap * 2; assert!( - engine.runtime.is_settling("tm-1", second_landed + ECHO_SETTLE_MS - 1), + engine + .runtime + .is_settling("tm-1", second_landed + ECHO_SETTLE_MS - 1), "the queued send's window had already been running for the length of its wait" ); - assert!(!engine.runtime.is_settling("tm-1", second_landed + ECHO_SETTLE_MS + 1)); + assert!(!engine + .runtime + .is_settling("tm-1", second_landed + ECHO_SETTLE_MS + 1)); } /// A second live terminal, with ids that share no substring with the first (§7.4). @@ -4297,7 +5272,10 @@ mod tests { tokio::time::sleep(Duration::from_millis(2_000)).await; } - let kinds: Vec = log_rows(&fake.store).into_iter().map(|(k, _, _)| k).collect(); + let kinds: Vec = log_rows(&fake.store) + .into_iter() + .map(|(k, _, _)| k) + .collect(); assert_eq!( kinds.iter().filter(|k| *k == "Sent").count(), 1, @@ -4311,7 +5289,12 @@ mod tests { again, and they evict the `sent` row: {:?}", kinds ); - assert_eq!(kinds.len(), 2, "and nothing else was written at all: {:?}", kinds); + assert_eq!( + kinds.len(), + 2, + "and nothing else was written at all: {:?}", + kinds + ); } /// **H-2: `automation:state` is an ARM TRANSITION event** (§7.2), and it fired only from a @@ -4343,18 +5326,29 @@ mod tests { fake.say("pc-1", "ctx:18%\n"); engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 2_500).await; - assert_eq!(fake.states.load(Ordering::Relaxed), 2, "a refused emit was dropped, not deferred"); + assert_eq!( + fake.states.load(Ordering::Relaxed), + 2, + "a refused emit was dropped, not deferred" + ); // Out of the settle window: Fired + false is a real transition, so it is announced. engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 9_000).await; - assert_eq!(engine.runtime.arm_state("au-1", "tm-1"), ArmState::re_armed()); + assert_eq!( + engine.runtime.arm_state("au-1", "tm-1"), + ArmState::re_armed() + ); assert_eq!(fake.states.load(Ordering::Relaxed), 3); // And still 18%: no transition at all now, so nothing to say however long we wait. engine.runtime.mark_dirty("pc-1"); evaluate_tick(&engine, &host, 0, 20_000).await; - assert_eq!(fake.states.load(Ordering::Relaxed), 3, "a repeat is not a transition"); + assert_eq!( + fake.states.load(Ordering::Relaxed), + 3, + "a repeat is not a transition" + ); } /// **H-6: the rule can go away during the queue wait too.** The wait is up to ten seconds; a user @@ -4366,7 +5360,7 @@ mod tests { let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); engine.complete_rule("au-1"); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; tokio::time::sleep(Duration::from_millis(1_500)).await; assert!(fake.written().is_empty(), "{:?}", fake.written()); @@ -4385,12 +5379,15 @@ mod tests { let (engine, fake, host) = wired(); let send = pending(&engine, &host, "au-1", ArmState::armed(), 1_000); - run_send(engine.clone(), host.clone(), send).await; + run_crossing(engine.clone(), host.clone(), send).await; tokio::time::sleep(Duration::from_millis(1_500)).await; let ids = fake.written_to(); assert!(!ids.is_empty(), "the premise: something was written"); - assert!(ids.iter().all(|id| id == "pc-1"), "a write was addressed by leaf id: {:?}", ids); + assert!( + ids.iter().all(|id| id == "pc-1"), + "a write was addressed by leaf id: {:?}", + ids + ); } - } From 9dac41f19bfa34b4cadf4a2fd4eebc11dd81c928 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 17:13:08 -0500 Subject: [PATCH 11/36] fix(automation): redact webhook endpoints --- src-tauri/src/automation_commands.rs | 55 ++++++++++- src-tauri/src/automation_engine.rs | 19 ++++ src-tauri/src/automation_store.rs | 75 ++++++++++++--- src-tauri/src/automation_webhook.rs | 20 ++++ .../components/Automation/AuActivityPane.tsx | 16 +++- .../components/Automation/AuInspector.tsx | 3 +- .../Automation/AutomationEditor.tsx | 17 ++-- .../__tests__/webhookRedaction.test.tsx | 96 +++++++++++++++++++ .../components/Automation/webhookRedaction.ts | 31 ++++++ .../Settings/Automations/ActivityLogView.tsx | 12 ++- .../Settings/Automations/AutomationsPanel.tsx | 5 +- .../Settings/Automations/activityLog.ts | 8 +- .../Settings/Automations/useAutomations.ts | 12 ++- 13 files changed, 326 insertions(+), 43 deletions(-) create mode 100644 src/renderer/components/Automation/__tests__/webhookRedaction.test.tsx create mode 100644 src/renderer/components/Automation/webhookRedaction.ts diff --git a/src-tauri/src/automation_commands.rs b/src-tauri/src/automation_commands.rs index 93a1952..7787b3c 100644 --- a/src-tauri/src/automation_commands.rs +++ b/src-tauri/src/automation_commands.rs @@ -44,6 +44,13 @@ fn to_string_err(e: AutomationStoreError) -> String { e.to_string() } +/// Decode an IPC rule without forwarding serde's rendering of the submitted value. A malformed +/// webhook object may contain its endpoint in that rendering, while callers only need to know that +/// the draft was malformed. +fn decode_automation_rule(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|_| "automation rule is malformed".to_string()) +} + /// One rule-level log row — no terminal, so no name to carry. /// /// **The three definition kinds had no writer at all.** `Saved` is required by §3.5 and checked by GUI @@ -298,9 +305,10 @@ fn reload_after_commit(owned: &AppState, at: i64) -> Result<(), String> { #[tauri::command] pub async fn dry_run_automation( state: State<'_, AppState>, - rule: AutomationRule, + rule: serde_json::Value, terminal_id: String, ) -> Result { + let rule = decode_automation_rule(rule)?; let owned = state.inner().clone(); tokio::task::spawn_blocking(move || { let engine = owned.automations.clone(); @@ -339,11 +347,11 @@ pub struct AutomationSaveResult { #[tauri::command] pub async fn save_automation( state: State<'_, AppState>, - rule: AutomationRule, + rule: serde_json::Value, origin: String, ) -> Result { let owned = state.inner().clone(); - let mut rule = rule; + let mut rule = decode_automation_rule(rule)?; if rule.id.trim().is_empty() { // The same shape `duplicate_automation` mints, and deliberately the same prefix: one id // vocabulary, minted in one crate. @@ -688,7 +696,46 @@ pub async fn rearm_automation( #[cfg(test)] mod source_tests { - use super::leaves_to_rearm; + use super::{decode_automation_rule, leaves_to_rearm}; + + #[test] + fn malformed_ipc_rule_errors_do_not_echo_a_webhook_url() { + let secret = "https://hooks.example.invalid/ipc-credential"; + let value = serde_json::json!({ + "id": "au-ipc", + "name": "bad webhook", + "enabled": false, + "runsOnce": false, + "targetMode": "pinned", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "excludedIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 3, + "graph": { "webhook": { + "provider": secret, + "url": secret, + "body": "done" + }}, + "createdAt": 0, + "updatedAt": 0 + }); + + // This is the exact serde decode that Tauri used to perform for the command argument. + // Its real error includes the invalid provider, so accepting a typed parameter leaked it. + let raw = serde_json::from_value::(value.clone()) + .expect_err("a URL is not a webhook provider") + .to_string(); + assert!(raw.contains(secret), "premise: raw IPC decode did not contain the URL: {raw}"); + + let safe = decode_automation_rule(value).expect_err("the command rejects malformed IPC"); + assert_eq!(safe, "automation rule is malformed"); + assert!(!safe.contains(secret)); + } /// `Re-arm now` reaches only pairs the rule actually watches. /// diff --git a/src-tauri/src/automation_engine.rs b/src-tauri/src/automation_engine.rs index e7efbbb..b1e8cf4 100644 --- a/src-tauri/src/automation_engine.rs +++ b/src-tauri/src/automation_engine.rs @@ -995,6 +995,25 @@ mod tests { assert!(rows[1].contains("needs a newer version"), "{}", rows[1]); } + #[test] + fn reload_logs_a_real_malformed_webhook_value_without_its_url() { + let secret = "https://hooks.example.invalid/reload-credential"; + let malformed = format!( + r#"{{"webhook":{{"provider":"{secret}","url":"{secret}","body":"done"}}}}"# + ); + let store = AutomationStore::new_in_memory(); + store.insert_raw_graph_for_test("au-malformed", &malformed); + + let engine = AutomationEngine::new(0); + let report = engine.reload(&store, 7_000).expect("reload malformed row"); + assert_eq!(report.skipped.len(), 1, "the malformed row was really skipped"); + assert!(!report.skipped[0].1.contains(secret), "reload reason leaked: {:?}", report.skipped); + + let rows = log_rows(&store); + assert_eq!(rows.len(), 1, "reload wrote its real activity row"); + assert!(!rows[0].contains(secret), "activity detail leaked: {}", rows[0]); + } + /// **A schedule rule has no pattern, and no pattern is not a broken pattern** (plan 032 §6.4). /// /// `reload` used to ask `pattern_refused_at_load(&graph.parse.find)` of every rule, and a rule diff --git a/src-tauri/src/automation_store.rs b/src-tauri/src/automation_store.rs index e9b2388..eec4cbb 100644 --- a/src-tauri/src/automation_store.rs +++ b/src-tauri/src/automation_store.rs @@ -892,9 +892,11 @@ fn read_rule_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { fn hydrate_rule(raw: RawRule) -> Result { Ok(AutomationRule { - graph: serde_json::from_str(&raw.graph).map_err(|e| { - AutomationStoreError::Invalid(format!("rule {}: bad graph blob: {e}", raw.id)) - })?, + // Serde can quote the malformed value in its error, and a graph contains the webhook URL. + // Keep the decode boundary opaque: list_rules turns this into a skipped-row reason and + // reload persists that reason to the activity log. + graph: serde_json::from_str(&raw.graph) + .map_err(|_| AutomationStoreError::Invalid(format!("rule {}: bad graph blob", raw.id)))?, target_mode: enum_from_db(&raw.target_mode)?, criterion: enum_from_db(&raw.criterion)?, exclude_criterion: raw.exclude_criterion.as_deref().map(enum_from_db).transpose()?, @@ -1037,6 +1039,25 @@ impl AutomationStore { store } + /// Insert a graph which could only have arrived from an older or corrupt store. + /// + /// This bypasses serialisation deliberately: tests need the real decode path to see whether a + /// malformed value reaches a user-facing skipped-row reason. + #[cfg(test)] + pub(crate) fn insert_raw_graph_for_test(&self, id: &str, graph: &str) { + let guard = self.conn.lock().unwrap(); + let conn = guard.as_ref().expect("in-memory store is connected"); + conn.execute( + "INSERT INTO automation_rules + (id, name, enabled, runs_once, target_mode, criterion, criterion_value, + follow_new, completed_at, verbose_until, sort_order, schema_version, + graph, created_at, updated_at) + VALUES (?1, 'bad', 1, 0, 'rule', 'allTerminals', '', 1, NULL, NULL, 2, 1, ?2, 1000, 1000)", + rusqlite::params![id, graph], + ) + .expect("insert raw graph"); + } + fn schema(conn: &Connection) -> rusqlite::Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS automation_rules ( @@ -2509,17 +2530,7 @@ mod tests { /// Insert a row whose `graph` column is arbitrary text, bypassing `save_rule`'s /// serialisation. There is no other way to author a row this build cannot decode. fn write_raw_graph(store: &AutomationStore, id: &str, graph: &str) { - let guard = store.conn.lock().unwrap(); - let conn = guard.as_ref().unwrap(); - conn.execute( - "INSERT INTO automation_rules - (id, name, enabled, runs_once, target_mode, criterion, criterion_value, - follow_new, completed_at, verbose_until, sort_order, schema_version, - graph, created_at, updated_at) - VALUES (?1, 'bad', 1, 0, 'rule', 'allTerminals', '', 1, NULL, NULL, 2, 1, ?2, 1000, 1000)", - rusqlite::params![id, graph], - ) - .unwrap(); + store.insert_raw_graph_for_test(id, graph); } fn rule_named(name: &str) -> AutomationRule { @@ -4342,6 +4353,42 @@ mod tests { ); } + #[test] + fn malformed_webhook_values_never_escape_decode_or_save_errors() { + let secret = "https://hooks.example.invalid/credential-token"; + let malformed = format!( + r#"{{"webhook":{{"provider":"{secret}","url":"{secret}","body":"done"}}}}"# + ); + + // This is the producer, not a hand-written error: serde really does quote the malformed + // provider value, which is why forwarding its Display would leak the URL. + let raw = serde_json::from_str::(&malformed) + .expect_err("a URL is not a webhook provider") + .to_string(); + assert!(raw.contains(secret), "premise: serde produced the secret: {raw}"); + + let store = AutomationStore::new_in_memory(); + write_raw_graph(&store, "au-malformed", &malformed); + assert!(store.list_rules().unwrap().is_empty()); + let skipped = store.take_skipped_rows(); + assert_eq!(skipped.len(), 1); + assert!(!skipped[0].1.contains(secret), "skipped row leaked: {:?}", skipped[0]); + assert!(skipped[0].1.contains("bad graph blob")); + + // Save errors also carry the store's Display through the command layer. Exercise the real + // enable/save validation with a well-typed webhook rule rather than inventing an error text. + let mut invalid = rule("au-save"); + invalid.graph.webhook = Some(WebhookStep { + provider: WebhookProvider::Discord, + url: secret.to_string(), + body: "done".to_string(), + substitute: false, + }); + invalid.graph.action.as_mut().expect("action fixture").message.clear(); + let error = store.save_rule(&invalid).expect_err("empty action is refused").to_string(); + assert!(!error.contains(secret), "save error leaked: {error}"); + } + // ----------------------------------------------------------------------------------------- // Plan 032 §5.2/§5.3 — `finds` (read depth) split from the per-clause `Test` (comparison). // ----------------------------------------------------------------------------------------- diff --git a/src-tauri/src/automation_webhook.rs b/src-tauri/src/automation_webhook.rs index 616f014..5768e2c 100644 --- a/src-tauri/src/automation_webhook.rs +++ b/src-tauri/src/automation_webhook.rs @@ -314,4 +314,24 @@ mod tests { "the error renders only its coarse class: {rendered:?}" ); } + + #[test] + fn webhook_and_graph_debug_never_render_the_endpoint() { + let secret = "https://hooks.example.invalid/debug-credential"; + let step = webhook(WebhookProvider::Discord, secret.to_string(), "done"); + let graph = AutomationGraph { + monitor: None, + parse: None, + cond: None, + timer: None, + action: None, + webhook: Some(step.clone()), + layout: None, + }; + + for rendered in [format!("{step:?}"), format!("{graph:?}")] { + assert!(rendered.contains(""), "debug did not mark the field: {rendered}"); + assert!(!rendered.contains(secret), "debug leaked the endpoint: {rendered}"); + } + } } diff --git a/src/renderer/components/Automation/AuActivityPane.tsx b/src/renderer/components/Automation/AuActivityPane.tsx index b5866ec..9403a74 100644 --- a/src/renderer/components/Automation/AuActivityPane.tsx +++ b/src/renderer/components/Automation/AuActivityPane.tsx @@ -18,6 +18,7 @@ import React from 'react'; import type { AutomationLogEntry } from '../../types/electron'; import { LOG_KIND_CLASS, LOG_KIND_LABEL, clockTime } from '../Settings/Automations/activityLog'; +import { redactWebhookLogEntry } from './webhookRedaction'; export interface AuActivityPaneProps { entries: AutomationLogEntry[]; @@ -32,7 +33,11 @@ export const AuActivityPane: React.FC = ({ error, saved, onOpenFullLog, -}) => ( +}) => { + // Keep this drawer independent of the Settings log's fetch path while applying the same + // last-resort redaction to every field it renders. + const redactedEntries = entries.map(redactWebhookLogEntry); + return (
{error !== null && (
@@ -43,7 +48,7 @@ export const AuActivityPane: React.FC = ({
)} - {error === null && entries.length === 0 && ( + {error === null && redactedEntries.length === 0 && (
No activity yet.
@@ -53,10 +58,10 @@ export const AuActivityPane: React.FC = ({
)} - {error === null && entries.length > 0 && ( + {error === null && redactedEntries.length > 0 && ( <>
- {entries.map((entry) => ( + {redactedEntries.map((entry) => (
{clockTime(entry.at)} @@ -78,4 +83,5 @@ export const AuActivityPane: React.FC = ({ )}
-); + ); +}; diff --git a/src/renderer/components/Automation/AuInspector.tsx b/src/renderer/components/Automation/AuInspector.tsx index 493ce1a..d807a46 100644 --- a/src/renderer/components/Automation/AuInspector.tsx +++ b/src/renderer/components/Automation/AuInspector.tsx @@ -21,6 +21,7 @@ import { ParsePanel } from './panels/ParsePanel'; import { CondPanel } from './panels/CondPanel'; import { TimerPanel } from './panels/TimerPanel'; import { ActionPanel } from './panels/ActionPanel'; +import { redactWebhookText } from './webhookRedaction'; export interface AuInspectorProps { draft: AutomationDraft; @@ -160,7 +161,7 @@ const ProblemList: React.FC<{ problems: Problem[]; onFocusStep: (step: StepKind) {problems.map((p) => (
  • ))} diff --git a/src/renderer/components/Automation/AutomationEditor.tsx b/src/renderer/components/Automation/AutomationEditor.tsx index 69d209e..7ea039d 100644 --- a/src/renderer/components/Automation/AutomationEditor.tsx +++ b/src/renderer/components/Automation/AutomationEditor.tsx @@ -63,6 +63,7 @@ import { AuCanvas } from './AuCanvas'; import { AuPalette } from './AuPalette'; import { AuInspector } from './AuInspector'; import { AuDrawer } from './AuDrawer'; +import { redactWebhookError } from './webhookRedaction'; import type { DrawerTab } from './AuDrawer'; import { useAuPaletteDrag } from './useAuPaletteDrag'; import './auToggle.css'; @@ -227,7 +228,7 @@ export const AutomationEditor: React.FC = ({ setTerminals(rows); setTerminalsError(null); } catch (e) { - setTerminalsError(e instanceof Error ? e.message : String(e)); + setTerminalsError(redactWebhookError(e)); } finally { setTerminalsLoading(false); } @@ -267,7 +268,7 @@ export const AutomationEditor: React.FC = ({ setEntries(await api.loadAutomationLog(draft.rule.id, true, DRAWER_LOG_LIMIT)); setLogError(null); } catch (e) { - setLogError(e instanceof Error ? e.message : String(e)); + setLogError(redactWebhookError(e)); } }, [api, draft.rule.id]); @@ -365,7 +366,7 @@ export const AutomationEditor: React.FC = ({ } catch (e) { // Reported, and REFUSED. The navigation guard reads this boolean, so swallowing the // error here would let a failed save close the editor and take the draft with it. - toast(`Could not save: ${e instanceof Error ? e.message : String(e)}`, 'error'); + toast(`Could not save: ${redactWebhookError(e)}`, 'error'); return false; } finally { inFlight.current = false; @@ -442,7 +443,7 @@ export const AutomationEditor: React.FC = ({ setReport(await api.dryRunAutomation(writing, target)); } catch (e) { setReport(null); - setTestError(e instanceof Error ? e.message : String(e)); + setTestError(redactWebhookError(e)); } finally { setRunning(false); } @@ -490,7 +491,7 @@ export const AutomationEditor: React.FC = ({ // The BACKEND owns "is this rule allowed to run" and re-checks — a refusal here is the // authority disagreeing with this renderer's own validation, which is exactly the case // the mirror exists for and must not be hidden. - toast(`Could not switch it ${enabled ? 'on' : 'off'}: ${e instanceof Error ? e.message : String(e)}`, 'error'); + toast(`Could not switch it ${enabled ? 'on' : 'off'}: ${redactWebhookError(e)}`, 'error'); } }; @@ -705,7 +706,7 @@ export const AutomationEditor: React.FC = ({ await onChanged(); toast('Duplicated — the copy is in the list, switched off.', 'success'); } catch (e) { - toast(`Could not duplicate: ${e instanceof Error ? e.message : String(e)}`, 'error'); + toast(`Could not duplicate: ${redactWebhookError(e)}`, 'error'); } })(); }} @@ -789,7 +790,7 @@ export const AutomationEditor: React.FC = ({ await onChanged(); toast('Re-armed — it can fire again on the next crossing.', 'success'); } catch (e) { - toast(`Could not re-arm: ${e instanceof Error ? e.message : String(e)}`, 'error'); + toast(`Could not re-arm: ${redactWebhookError(e)}`, 'error'); } })(); } @@ -838,7 +839,7 @@ export const AutomationEditor: React.FC = ({ await onChanged(); onClose(); } catch (e) { - toast(`Could not delete: ${e instanceof Error ? e.message : String(e)}`, 'error'); + toast(`Could not delete: ${redactWebhookError(e)}`, 'error'); } })(); }} diff --git a/src/renderer/components/Automation/__tests__/webhookRedaction.test.tsx b/src/renderer/components/Automation/__tests__/webhookRedaction.test.tsx new file mode 100644 index 0000000..976b892 --- /dev/null +++ b/src/renderer/components/Automation/__tests__/webhookRedaction.test.tsx @@ -0,0 +1,96 @@ +/** + * @jest-environment jsdom + * + * Webhook redaction covers the enumerated surfaces below. This is intentionally not a derived + * registry: adding a future display surface does not make this test find it automatically. + */ +import React, { act } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import type { AutomationLogEntry } from '../../../types/electron'; +import { ActivityLogView } from '../../Settings/Automations/ActivityLogView'; +import { logCopyText } from '../../Settings/Automations/activityLog'; +import { + REDACTED_WEBHOOK_URL, + redactWebhookError, + redactWebhookLogEntry, +} from '../webhookRedaction'; + +const secret = 'https://hooks.example.invalid/renderer-credential'; + +function entry(over: Partial = {}): AutomationLogEntry { + return { + id: 1, + ruleId: 'au-1', + terminalId: 'tm-1', + terminalName: secret, + kind: 'failed', + detail: `webhook failed: ${secret}`, + at: new Date(2026, 8, 4, 9, 11, 5).getTime(), + ...over, + }; +} + +describe('webhook redaction — enumerated surfaces only', () => { + it('redacts every serialised activity entry field, not only detail', () => { + const source = entry({ ruleId: secret, terminalId: secret }); + const safe = redactWebhookLogEntry(source); + expect(JSON.stringify(safe)).toBe(JSON.stringify({ + ...source, + ruleId: REDACTED_WEBHOOK_URL, + terminalId: REDACTED_WEBHOOK_URL, + terminalName: REDACTED_WEBHOOK_URL, + detail: `webhook failed: ${REDACTED_WEBHOOK_URL}`, + })); + }); + + it('copies the actual redacted clipboard text, not merely the rendered row', () => { + const copied = logCopyText([entry()]); + expect(copied).toBe( + `09:11:05\ttm-1 ${REDACTED_WEBHOOK_URL}\tfailed\twebhook failed: ${REDACTED_WEBHOOK_URL}`, + ); + expect(copied).not.toContain(secret); + }); + + it('passes the exact redacted export text to the Activity Log Copy action', async () => { + const writeText = jest.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root: Root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + await act(async () => { + [...container.querySelectorAll('button')].find((button) => button.textContent === 'Copy')!.click(); + }); + + expect(writeText).toHaveBeenCalledWith( + `09:11:05\ttm-1 ${REDACTED_WEBHOOK_URL}\tfailed\twebhook failed: ${REDACTED_WEBHOOK_URL}`, + ); + expect(container.textContent).not.toContain(secret); + await act(async () => root.unmount()); + container.remove(); + }); + + it('redacts the error text that save, enable, and duplicate toast paths receive', () => { + const error = new Error(`automation store rejected the value: ${secret}`); + expect(redactWebhookError(error)).toBe( + `automation store rejected the value: ${REDACTED_WEBHOOK_URL}`, + ); + }); +}); diff --git a/src/renderer/components/Automation/webhookRedaction.ts b/src/renderer/components/Automation/webhookRedaction.ts new file mode 100644 index 0000000..6063f87 --- /dev/null +++ b/src/renderer/components/Automation/webhookRedaction.ts @@ -0,0 +1,31 @@ +/** + * Last-resort display redaction for text returned across the automation IPC boundary. + * + * The Rust producers avoid constructing URL-bearing errors, but a corrupt historical log entry or + * a framework-level decode failure must not make the webhook endpoint visible in a toast, inspector, + * activity row, or clipboard export. + */ +import type { AutomationLogEntry } from '../../types/electron'; + +export const REDACTED_WEBHOOK_URL = ''; + +const WEBHOOK_URL = /\bhttps?:\/\/[^\s<>"'\\]+/giu; + +export function redactWebhookText(value: string): string { + return value.replace(WEBHOOK_URL, REDACTED_WEBHOOK_URL); +} + +export function redactWebhookError(error: unknown): string { + return redactWebhookText(error instanceof Error ? error.message : String(error)); +} + +/** Redact every serialised activity field that can reach a display or export surface. */ +export function redactWebhookLogEntry(entry: AutomationLogEntry): AutomationLogEntry { + return { + ...entry, + ruleId: redactWebhookText(entry.ruleId), + terminalId: entry.terminalId == null ? entry.terminalId : redactWebhookText(entry.terminalId), + terminalName: entry.terminalName == null ? entry.terminalName : redactWebhookText(entry.terminalName), + detail: redactWebhookText(entry.detail), + }; +} diff --git a/src/renderer/components/Settings/Automations/ActivityLogView.tsx b/src/renderer/components/Settings/Automations/ActivityLogView.tsx index cae373a..be5e1f2 100644 --- a/src/renderer/components/Settings/Automations/ActivityLogView.tsx +++ b/src/renderer/components/Settings/Automations/ActivityLogView.tsx @@ -22,6 +22,7 @@ import { passesFilter, rowTime, } from './activityLog'; +import { redactWebhookLogEntry } from '../../Automation/webhookRedaction'; /** * How long *Log every check* stays on before it turns itself off again. @@ -66,15 +67,20 @@ export const ActivityLogView: React.FC = ({ const isVerbose = rule?.verboseUntil !== null && rule?.verboseUntil !== undefined && rule.verboseUntil > now; + const redactedEntries = useMemo( + () => entries.map(redactWebhookLogEntry), + [entries], + ); + const rows = useMemo( - () => collapseRuns(entries.filter((e) => passesFilter(e, filter))), - [entries, filter], + () => collapseRuns(redactedEntries.filter((e) => passesFilter(e, filter))), + [redactedEntries, filter], ); const copy = () => { // Expanded, always: a log pasted into a bug report with seven decisions replaced by the // words "7 identical decisions collapsed" has lost the timestamps that made it evidence. - void navigator.clipboard?.writeText(logCopyText(entries)); + void navigator.clipboard?.writeText(logCopyText(redactedEntries)); }; return ( diff --git a/src/renderer/components/Settings/Automations/AutomationsPanel.tsx b/src/renderer/components/Settings/Automations/AutomationsPanel.tsx index 9b2e025..39f9a71 100644 --- a/src/renderer/components/Settings/Automations/AutomationsPanel.tsx +++ b/src/renderer/components/Settings/Automations/AutomationsPanel.tsx @@ -16,6 +16,7 @@ import { AutomationRow } from './AutomationRow'; import { TemplateGallery } from './TemplateGallery'; import { ActivityLogView } from './ActivityLogView'; import { AutomationEditor } from '../../Automation/AutomationEditor'; +import { redactWebhookError } from '../../Automation/webhookRedaction'; import type { CanvasOpening } from '../../Automation/automationDraft'; import { automationRowState, JUST_FIRED_MS } from './automationState'; import { useAutomations } from './useAutomations'; @@ -80,7 +81,7 @@ export const AutomationsPanel: React.FC = () => { await fn(); await refresh(); } catch (e) { - setActionError(`${what} failed: ${e instanceof Error ? e.message : String(e)}`); + setActionError(`${what} failed: ${redactWebhookError(e)}`); } }; @@ -106,7 +107,7 @@ export const AutomationsPanel: React.FC = () => { } await refresh(); } catch (e) { - setActionError(`${what} failed: ${e instanceof Error ? e.message : String(e)}`); + setActionError(`${what} failed: ${redactWebhookError(e)}`); } }; diff --git a/src/renderer/components/Settings/Automations/activityLog.ts b/src/renderer/components/Settings/Automations/activityLog.ts index 312de25..4267c38 100644 --- a/src/renderer/components/Settings/Automations/activityLog.ts +++ b/src/renderer/components/Settings/Automations/activityLog.ts @@ -12,6 +12,7 @@ * rewrite the past, which is the same mistake as looking a terminal's name up instead of storing it. */ import type { AutomationLogEntry, AutomationLogKind } from '../../../types/electron'; +import { redactWebhookLogEntry } from '../../Automation/webhookRedaction'; /** The word the log column shows, in the mockup's own vocabulary. */ export const LOG_KIND_LABEL: Record = { @@ -162,9 +163,10 @@ export function collapsedDetail(row: LogRow): string { export function logCopyText(entries: AutomationLogEntry[]): string { return entries .map((e) => { - const who = e.terminalId ?? '—'; - const name = e.terminalName ? ` ${e.terminalName}` : ''; - return `${clockTime(e.at)}\t${who}${name}\t${LOG_KIND_LABEL[e.kind]}\t${e.detail}`; + const safe = redactWebhookLogEntry(e); + const who = safe.terminalId ?? '—'; + const name = safe.terminalName ? ` ${safe.terminalName}` : ''; + return `${clockTime(safe.at)}\t${who}${name}\t${LOG_KIND_LABEL[safe.kind]}\t${safe.detail}`; }) .join('\n'); } diff --git a/src/renderer/components/Settings/Automations/useAutomations.ts b/src/renderer/components/Settings/Automations/useAutomations.ts index d1c5b2f..0eac1c3 100644 --- a/src/renderer/components/Settings/Automations/useAutomations.ts +++ b/src/renderer/components/Settings/Automations/useAutomations.ts @@ -20,6 +20,7 @@ import { AUTOMATION_STATE, } from '../../../services/automationEvents'; import { mergeEntries } from './activityLog'; +import { redactWebhookError, redactWebhookLogEntry } from '../../Automation/webhookRedaction'; /** * The log the panel holds in memory. The store keeps 200 per rule; holding more here would be a @@ -103,7 +104,7 @@ export function useAutomations(): UseAutomations { setError(null); } catch (e) { if (genRef.current !== gen) return; - setError(e instanceof Error ? e.message : String(e)); + setError(redactWebhookError(e)); } finally { if (genRef.current === gen) setLoading(false); } @@ -124,7 +125,12 @@ export function useAutomations(): UseAutomations { // Merged rather than replaced: an `automation:activity` event can arrive while this // request is in flight, and the entry it stands for must not be dropped by a response // that was assembled before it existed. - setLog((prev) => mergeEntries(prev, rows, scope.newestFirst, LOG_BUFFER_MAX)); + setLog((prev) => mergeEntries( + prev, + rows.map(redactWebhookLogEntry), + scope.newestFirst, + LOG_BUFFER_MAX, + )); setLogError(null); } catch (e) { if (genRef.current !== gen || scopeRef.current !== scope) return; @@ -135,7 +141,7 @@ export function useAutomations(): UseAutomations { // which is a confident, specific lie about a store that is refusing to answer. §7.8 // assigns the `Disabled` state to the panel AND the log view, separately, for this // reason. - setLogError(e instanceof Error ? e.message : String(e)); + setLogError(redactWebhookError(e)); } }, []); From fa99b03ac8e34df4b4a7ada22e8c3478cb94c375 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 18:20:26 -0500 Subject: [PATCH 12/36] feat(automation): validate webhook rules --- src-tauri/src/automation_engine/loops.rs | 19 +- src-tauri/src/automation_store.rs | 16 +- src-tauri/src/automation_validation.rs | 103 +++++++- .../automationValidationCases.json | 228 ++++++++++++++++++ .../__tests__/automationValidation.test.ts | 2 +- .../automationWritesWhatIsDrawn.test.ts | 8 +- .../Automation/automationValidation.ts | 123 +++++++++- 7 files changed, 475 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/automation_engine/loops.rs b/src-tauri/src/automation_engine/loops.rs index 3a0a4d0..cdab623 100644 --- a/src-tauri/src/automation_engine/loops.rs +++ b/src-tauri/src/automation_engine/loops.rs @@ -92,7 +92,12 @@ pub async fn run_tap( #[cfg(test)] mod task8_tests { use super::*; - use crate::automation_engine::test_host::{ctx_rule, rig_with_rule, strip_comments, wire}; + use crate::automation_engine::test_host::{ + ctx_rule, + rig_with_rule_bypassing_the_enable_gate, + strip_comments, + wire_bypassing_the_enable_gate, + }; use crate::automation_store::{LogOrder, LogScope, WebhookProvider, WebhookStep}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -192,7 +197,7 @@ mod task8_tests { /// its malformed webhook endpoint fails. That failure must not suppress the terminal delivery. #[tokio::test] async fn a_failed_webhook_leaves_the_terminal_send_alone() { - let (engine, fake, host) = rig_with_rule(|graph| { + let (engine, fake, host) = rig_with_rule_bypassing_the_enable_gate(|graph| { // reqwest rejects this while building the request; it cannot leave this machine. add_discord_webhook(graph, "not a valid URL".into()); }); @@ -233,7 +238,7 @@ mod task8_tests { let mut rule = ctx_rule("au-1"); rule.runs_once = true; add_discord_webhook(&mut rule.graph, url); - let (engine, fake, host) = wire(vec![rule]); + let (engine, fake, host) = wire_bypassing_the_enable_gate(vec![rule]); let send = pending(&engine, &host, ArmState::armed(), 4_000); fake.close("tm-1"); @@ -263,7 +268,8 @@ mod task8_tests { #[tokio::test] async fn a_crossing_records_one_fire_however_many_destinations_it_had() { let (url, requested) = webhook_endpoint(); - let (engine, _fake, host) = rig_with_rule(|graph| add_discord_webhook(graph, url)); + let (engine, _fake, host) = + rig_with_rule_bypassing_the_enable_gate(|graph| add_discord_webhook(graph, url)); let send = pending(&engine, &host, ArmState::armed(), 4_000); run_crossing(engine.clone(), host.clone(), send).await; @@ -286,7 +292,7 @@ mod task8_tests { let mut rule = ctx_rule("au-1"); rule.runs_once = true; add_discord_webhook(&mut rule.graph, url); - let (engine, fake, host) = wire(vec![rule]); + let (engine, fake, host) = wire_bypassing_the_enable_gate(vec![rule]); let send = pending(&engine, &host, ArmState::armed(), 4_000); let task = tokio::spawn(run_crossing(engine.clone(), host.clone(), send)); @@ -324,7 +330,8 @@ mod task8_tests { #[tokio::test] async fn one_crossing_with_two_destinations_writes_one_row_each() { let (url, requested) = webhook_endpoint(); - let (engine, fake, host) = rig_with_rule(|graph| add_discord_webhook(graph, url)); + let (engine, fake, host) = + rig_with_rule_bypassing_the_enable_gate(|graph| add_discord_webhook(graph, url)); let send = pending(&engine, &host, ArmState::armed(), 4_242); run_crossing(engine.clone(), host.clone(), send).await; diff --git a/src-tauri/src/automation_store.rs b/src-tauri/src/automation_store.rs index eec4cbb..84eb9a3 100644 --- a/src-tauri/src/automation_store.rs +++ b/src-tauri/src/automation_store.rs @@ -4376,16 +4376,20 @@ mod tests { assert!(skipped[0].1.contains("bad graph blob")); // Save errors also carry the store's Display through the command layer. Exercise the real - // enable/save validation with a well-typed webhook rule rather than inventing an error text. + // enable/save validation with a well-typed, secret-bearing webhook rule rather than + // inventing an error text. let mut invalid = rule("au-save"); invalid.graph.webhook = Some(WebhookStep { - provider: WebhookProvider::Discord, + provider: WebhookProvider::Custom, url: secret.to_string(), - body: "done".to_string(), - substitute: false, + body: r#"{\"result\": ${value}}"#.to_string(), + substitute: true, }); - invalid.graph.action.as_mut().expect("action fixture").message.clear(); - let error = store.save_rule(&invalid).expect_err("empty action is refused").to_string(); + invalid.graph.parse.as_mut().expect("parse fixture").find = r"(?\\w+)".to_string(); + let error = store + .save_rule(&invalid) + .expect_err("post-substitution custom JSON is refused") + .to_string(); assert!(!error.contains(secret), "save error leaked: {error}"); } diff --git a/src-tauri/src/automation_validation.rs b/src-tauri/src/automation_validation.rs index 3371490..8144c34 100644 --- a/src-tauri/src/automation_validation.rs +++ b/src-tauri/src/automation_validation.rs @@ -22,12 +22,15 @@ //! about the parse step alone and need nothing but the graph. **M3 lands the whole-rule rules** (no //! terminals, empty message, the echo warning) with the enable path, and **M5 the shared fixture**. +use std::collections::BTreeMap; + use regex::{Regex, RegexBuilder}; +use reqwest::Url; -use crate::automation_engine::subst; +use crate::automation_engine::{eval, subst}; use crate::automation_store::{ AutomationGraph, AutomationRule, Cadence, Criterion, Finds, Keep, ParseStep, Source, TargetMode, - Test, TextOp, TimerMode, TimerStep, MINUTES_PER_DAY, WEEKDAY_BITS_MASK, + Test, TextOp, TimerMode, TimerStep, WebhookProvider, MINUTES_PER_DAY, WEEKDAY_BITS_MASK, }; /// Whether a problem stops the rule running, or merely tells the user something. @@ -129,6 +132,34 @@ fn token_supplied(compiled: &Regex, group: Option, name: Option<&str>) -> } } +fn sample_webhook_captures(compiled: &Regex) -> eval::Captures { + let groups = (0..compiled.captures_len()) + .map(|index| Some(format!("[g{index}]"))) + .collect(); + let named = compiled + .capture_names() + .filter_map(|name| name.map(|name| (name.to_string(), Some(format!("[{name}]"))))) + .collect::>(); + + eval::Captures { groups, named } +} + +fn rendered_webhook_body(webhook: &crate::automation_store::WebhookStep, parse: Option<&ParseStep>) -> String { + if !webhook.substitute { + return webhook.body.clone(); + } + let Some(parse) = parse else { + return webhook.body.clone(); + }; + let Ok(compiled) = compile(&parse.find) else { + return webhook.body.clone(); + }; + match subst::substitute(&webhook.body, Some(&sample_webhook_captures(&compiled))) { + Ok(body) => body, + Err(_) => webhook.body.clone(), + } +} + /// `$0` / `$2` / `${name}`, for a clause's own problem message. /// /// `pub(crate)` rather than a second copy: `dry.rs`'s Test-pane wording for a clause list needs the @@ -643,8 +674,19 @@ pub fn problems(rule: &AutomationRule) -> Vec { out.extend(timer_problems(&rule.graph)); // --- message -------------------------------------------------------------------------------- - if (rule.graph.action.is_none() && rule.graph.webhook.is_none()) - || rule.graph.action.as_ref().is_some_and(|action| action.message.trim().is_empty()) + if rule.graph.action.is_none() && rule.graph.webhook.is_none() { + out.push(Problem::new( + Severity::Blocks, + "action", + "rule.noDestination", + "Add a terminal message or a webhook destination.", + )); + } else if rule.graph.webhook.is_none() + && rule + .graph + .action + .as_ref() + .is_some_and(|action| action.message.trim().is_empty()) { out.push(Problem::new( Severity::Blocks, @@ -737,6 +779,53 @@ pub fn problems(rule: &AutomationRule) -> Vec { } } + // --- webhook --------------------------------------------------------------------------------- + if let Some(webhook) = rule.graph.webhook.as_ref() { + if webhook.url.trim().is_empty() { + out.push(Problem::new( + Severity::Blocks, + "action", + "webhook.urlEmpty", + "Provide a webhook URL.", + )); + } else if let Ok(url) = Url::parse(webhook.url.trim()) { + if url.scheme() != "https" { + out.push(Problem::new( + Severity::Blocks, + "action", + "webhook.urlNotHttps", + "Provide an https webhook URL.", + )); + } + } else { + out.push(Problem::new( + Severity::Blocks, + "action", + "webhook.urlMalformed", + "Provide a well-formed webhook URL.", + )); + } + + if webhook.body.trim().is_empty() { + out.push(Problem::new( + Severity::Blocks, + "action", + "webhook.bodyEmpty", + "Enter a webhook body.", + )); + } else if webhook.provider == WebhookProvider::Custom { + let rendered_body = rendered_webhook_body(webhook, parse_step(&rule.graph)); + if serde_json::from_str::(&rendered_body).is_err() { + out.push(Problem::new( + Severity::Blocks, + "action", + "webhook.bodyNotJson", + "The webhook body must be valid JSON.", + )); + } + } + } + // STABLE, so within each severity the problems stay in step order — targets, monitor, parse, // cond, timer, action — which is the order the inspector's problem list draws them in. out.sort_by_key(|p| !p.blocks()); @@ -1137,6 +1226,12 @@ mod tests { "action.echo", "action.tokenWithoutParse", "action.unknownToken", + "rule.noDestination", + "webhook.urlEmpty", + "webhook.urlMalformed", + "webhook.urlNotHttps", + "webhook.bodyEmpty", + "webhook.bodyNotJson", ] { assert!(codes.contains(code), "no fixture case produces `{code}`"); } diff --git a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json index b0f765b..4dd31b1 100644 --- a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json +++ b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json @@ -3331,6 +3331,234 @@ "code": "timer.scheduleWithMonitor" } ] + }, + { + "name": "Webhook — must have a destination", + "why": "A rule with no action and no webhook is blocked before save or enable.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "commandContains", + "criterionValue": "claude", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "monitor": { + "read": "newOutput", + "cadence": "onOutput", + "everyMs": 30000 + }, + "parse": { + "preset": "custom", + "literal": null, + "find": "ctx:(\\d+)%", + "keep": "brackets" + }, + "cond": { + "kind": "number", + "op": "gt", + "threshold": 25 + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "rule.noDestination" + } + ] + }, + { + "name": "Webhook — URL is required", + "why": "An empty webhook URL is blocked before save or enable.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "webhook": { + "provider": "discord", + "url": "", + "body": "build failed" + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "webhook.urlEmpty" + } + ] + }, + { + "name": "Webhook — malformed URL is blocked", + "why": "A non-URL string cannot be used as a webhook destination.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "webhook": { + "provider": "discord", + "url": "not-a-url", + "body": "build failed" + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "webhook.urlMalformed" + } + ] + }, + { + "name": "Webhook — URL must be https", + "why": "An HTTP webhook destination is blocked because plaintext webhook traffic is unsafe.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "webhook": { + "provider": "discord", + "url": "http://hooks.example.invalid/relay", + "body": "build failed" + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "webhook.urlNotHttps" + } + ] + }, + { + "name": "Webhook — body is required", + "why": "A blank webhook body cannot be sent.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "webhook": { + "provider": "discord", + "url": "https://example.invalid/hook", + "body": "" + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "webhook.bodyEmpty" + } + ] + }, + { + "name": "Webhook — custom body must be JSON (post-substitution)", + "why": "Custom webhooks validate the resolved body; this template would not parse after substitution.", + "rule": { + "id": "au-fix", + "name": "Context handoff reminder", + "enabled": false, + "runsOnce": false, + "targetMode": "rule", + "criterion": "allTerminals", + "criterionValue": "", + "followNew": true, + "targetIds": [], + "completedAt": null, + "verboseUntil": null, + "sortOrder": 0, + "schemaVersion": 1, + "createdAt": 0, + "updatedAt": 0, + "graph": { + "parse": { + "preset": "custom", + "literal": null, + "find": "(?\\\\w+)", + "keep": "brackets" + }, + "webhook": { + "provider": "custom", + "url": "https://example.invalid/hook", + "body": "{\"k\": ${value}}", + "substitute": true + } + } + }, + "expected": [ + { + "severity": "blocks", + "field": "action", + "code": "webhook.bodyNotJson" + } + ] } ] } diff --git a/src/renderer/components/Automation/__tests__/automationValidation.test.ts b/src/renderer/components/Automation/__tests__/automationValidation.test.ts index 9ccac36..64612c3 100644 --- a/src/renderer/components/Automation/__tests__/automationValidation.test.ts +++ b/src/renderer/components/Automation/__tests__/automationValidation.test.ts @@ -67,7 +67,7 @@ describe('automationValidation — the shared fixture', () => { // "at least 20" and this test would stay green. `all.length` is DERIVED from `BADGES` // (never hand-typed), so bumping this number is the one place a new code cannot be added // silently — it forces a look at whether the fixture actually covers it. - expect(all.length).toBe(23); + expect(all.length).toBe(29); expect(all.filter((code) => !covered.has(code))).toEqual([]); }); }); diff --git a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts index 2d114ac..6d096cc 100644 --- a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts +++ b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts @@ -101,9 +101,9 @@ describe('the editor writes the steps the canvas draws', () => { * type"* — the first thing a new user sees after *Start from scratch*. The group omission * removes the first, because nothing on the canvas claims to read anything. * - * The second stays, and stays on purpose. `action` is the one step the DTO makes mandatory - * (§3.1: *"every rule in both scenarios ends in a send"*), so *"enter the message"* is true of a - * blank rule and not a claim about an undrawn card. Filtering it out is the alternative, and it + * The second stays, and stays on purpose. A blank rule has neither a terminal message nor a + * webhook destination, so reporting its missing destination is true of a blank rule and not a + * claim about an undrawn card. Filtering it out is the alternative, and it * would make the inspector's list disagree with the Enable gate beside it — `blocking.length` * is what dims the toggle, so a filtered list reads *no problems* over a control that refuses to * move and says nothing. One unfixable-looking sentence is worse than one true one. @@ -111,6 +111,6 @@ describe('the editor writes the steps the canvas draws', () => { it('a blank canvas reports exactly the one problem a blank rule really has', () => { const draft = draftFromRule(blankDraft(), 'blank'); expect(draft.present).toEqual([]); - expect(blockingCodes(draft)).toEqual(['action.empty']); + expect(blockingCodes(draft)).toEqual(['rule.noDestination']); }); }); diff --git a/src/renderer/components/Automation/automationValidation.ts b/src/renderer/components/Automation/automationValidation.ts index f029530..417ed1b 100644 --- a/src/renderer/components/Automation/automationValidation.ts +++ b/src/renderer/components/Automation/automationValidation.ts @@ -33,7 +33,7 @@ import type { AutomationRule, AutomationSource, } from '../../types/electron'; -import { tokensUsed } from './automationTokens'; +import { previewSubstitute, tokensUsed } from './automationTokens'; export type Severity = 'blocks' | 'warns'; @@ -69,6 +69,12 @@ export type ProblemCode = | 'timer.scheduleWithMonitor' | 'timer.neverRuns' | 'action.empty' + | 'rule.noDestination' + | 'webhook.urlEmpty' + | 'webhook.urlMalformed' + | 'webhook.urlNotHttps' + | 'webhook.bodyEmpty' + | 'webhook.bodyNotJson' | 'action.echo' | 'action.tokenWithoutParse' | 'action.unknownToken'; @@ -230,6 +236,41 @@ function parseStep(graph: AutomationGraph): AutomationParseStep | null { return parse && parse.find.trim().length > 0 ? parse : null; } +function webhookSampleValues(groups: { count: number; names: Set }): Record { + const sample: Record = {}; + for (let index = 0; index <= groups.count; index += 1) { + sample[String(index)] = `[g${index}]`; + } + for (const name of groups.names) { + sample[name] = `[${name}]`; + } + return sample; +} + +function renderWebhookBodyForValidation( + webhook: NonNullable, + parse: AutomationParseStep | null, +): string { + if (!webhook.substitute || !parse || webhook.body.trim().length === 0) { + return webhook.body; + } + + const groups = groupsOf(parse.find); + if (compilePattern(parse.find) === null) { + return webhook.body; + } + + const sample = webhookSampleValues(groups); + const rendered = previewSubstitute(webhook.body, groups, sample); + if (!rendered.ok) { + return webhook.body; + } + + return rendered.parts + .map((p) => (p.kind === 'text' ? p.text : p.token)) + .join(''); +} + /** * Everything wrong with the COND step's clause list — §8's four `cond.*` codes (plan 032 §5.3, * §5.4). @@ -610,7 +651,7 @@ export function patternProblems(graph: AutomationGraph): Problem[] { */ export function problems(rule: AutomationRule): Problem[] { const out: Problem[] = []; - const { monitor, parse, cond, action } = rule.graph; + const { monitor, parse, cond, action, webhook } = rule.graph; // --- target ---------------------------------------------------------------------------------- // Only a PINNED rule can be empty in a way validation can see. A criterion rule that currently @@ -719,7 +760,16 @@ export function problems(rule: AutomationRule): Problem[] { out.push(...timerProblems(rule.graph)); // --- message --------------------------------------------------------------------------------- - if ((!action && !rule.graph.webhook) || action?.message.trim().length === 0) { + if (!action && !webhook) { + out.push( + problem( + 'blocks', + 'action', + 'rule.noDestination', + 'Add a terminal message or a webhook destination.', + ), + ); + } else if (action?.message.trim().length === 0 && !webhook) { out.push( problem( 'blocks', @@ -752,6 +802,67 @@ export function problems(rule: AutomationRule): Problem[] { } } + if (webhook) { + if (webhook.url.trim().length === 0) { + out.push( + problem( + 'blocks', + 'action', + 'webhook.urlEmpty', + 'Provide a webhook URL.', + ), + ); + } else { + try { + const parsed = new URL(webhook.url.trim()); + if (parsed.protocol !== 'https:') { + out.push( + problem( + 'blocks', + 'action', + 'webhook.urlNotHttps', + 'Provide an https webhook URL.', + ), + ); + } + } catch { + out.push( + problem( + 'blocks', + 'action', + 'webhook.urlMalformed', + 'Provide a well-formed webhook URL.', + ), + ); + } + } + + if (webhook.body.trim().length === 0) { + out.push( + problem( + 'blocks', + 'action', + 'webhook.bodyEmpty', + 'Enter a webhook body.', + ), + ); + } else if (webhook.provider === 'custom') { + const rendered = renderWebhookBodyForValidation(webhook, parseStep(rule.graph)); + try { + JSON.parse(rendered); + } catch { + out.push( + problem( + 'blocks', + 'action', + 'webhook.bodyNotJson', + 'The webhook body must be valid JSON.', + ), + ); + } + } + } + // --- token substitution ---------------------------------------------------------------------- // §4.4, opt-in via `ActionStep.substitute` (plan 032 §4.2). Without this, a message naming a // token the pattern cannot supply reaches `subst::substitute` only at SEND time, where §4.4's @@ -847,6 +958,12 @@ export const BADGES: Record = { 'timer.scheduleWithMonitor': 'the watch is ignored', 'timer.neverRuns': 'this rule can never run', 'action.empty': 'needs a message', + 'rule.noDestination': 'needs a destination', + 'webhook.urlEmpty': 'needs a webhook URL', + 'webhook.urlMalformed': 'needs a valid webhook URL', + 'webhook.urlNotHttps': 'needs an https webhook', + 'webhook.bodyEmpty': 'needs a webhook body', + 'webhook.bodyNotJson': 'webhook body is not valid JSON', 'action.echo': 'may read its own message', 'action.tokenWithoutParse': 'needs a pattern to capture from', 'action.unknownToken': 'names a value the pattern has not got', From 93b50d2932569275de1f033ac0909c0974faf532 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 18:31:27 -0500 Subject: [PATCH 13/36] feat(automation): draw webhook destinations on canvas --- src/renderer/components/Automation/AuNode.tsx | 3 +- .../__tests__/automationPanelsRender.test.tsx | 2 + .../__tests__/automationRoundTrip.test.ts | 20 ++--- .../__tests__/automationSteps.test.ts | 46 +++++++++--- .../automationWritesWhatIsDrawn.test.ts | 75 +++++++++++++++++++ .../components/Automation/automationDerive.ts | 16 +++- .../components/Automation/automationDraft.ts | 39 +++++++++- .../components/Automation/automationSteps.ts | 23 ++++-- 8 files changed, 189 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/Automation/AuNode.tsx b/src/renderer/components/Automation/AuNode.tsx index 143d445..a228491 100644 --- a/src/renderer/components/Automation/AuNode.tsx +++ b/src/renderer/components/Automation/AuNode.tsx @@ -16,7 +16,7 @@ import { STEP_PORTS } from './automationSteps'; import { AU_NODE_H, AU_NODE_W, sideOf } from './automationDraft'; import type { PortSide } from './automationDraft'; -/** The five accents, from the mockup's own palette. */ +/** The step glyphs, from the mockup's own palette. */ export const STEP_GLYPHS: Record = { monitor: '◉', parse: '⌥', @@ -25,6 +25,7 @@ export const STEP_GLYPHS: Record = { // both a delay and a time of day, and a stopwatch is the one of the three that reads as either. timer: '⏱', action: '▶', + webhook: '↗', }; export interface AuNodeProps { diff --git a/src/renderer/components/Automation/__tests__/automationPanelsRender.test.tsx b/src/renderer/components/Automation/__tests__/automationPanelsRender.test.tsx index dee26cc..7722d28 100644 --- a/src/renderer/components/Automation/__tests__/automationPanelsRender.test.tsx +++ b/src/renderer/components/Automation/__tests__/automationPanelsRender.test.tsx @@ -183,6 +183,8 @@ describe('the inspector panels — rendered, per template', () => { // sentence about a missing step both surfaces state in prose. timer: ['when'], action: ['message', 'send'], + // Task 11 adds the canvas card. Its editable inspector is deliberately Task 12. + webhook: [], }; const rule = draftFromTemplate(AUTOMATION_TEMPLATES[0]); for (const step of STEP_ORDER) { diff --git a/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts b/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts index d8acea4..5f52c2a 100644 --- a/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts +++ b/src/renderer/components/Automation/__tests__/automationRoundTrip.test.ts @@ -29,7 +29,8 @@ import { problems } from '../automationValidation'; import { STEP_ORDER, canAddStep, defaultWires } from '../automationSteps'; /** The steps an ordinary v1 rule has, and therefore the cards it opens with — the wait is opt-in. */ -const WITHOUT_TIMER = STEP_ORDER.filter((s) => s !== 'timer'); +const WITHOUT_TIMER = STEP_ORDER.filter((s) => s !== 'timer' && s !== 'webhook'); +const WITH_TIMER = STEP_ORDER.filter((s) => s !== 'webhook'); import type { AutomationRule } from '../../../types/electron'; /** The wire hop, exactly as `invoke` performs it. */ @@ -124,6 +125,7 @@ describe('draft ⇄ row', () => { // asserting the default rather than the carried value. timer: { x: 51, y: 52 }, action: { x: 41, y: 42 }, + webhook: { x: 61, y: 62 }, }, }, }; @@ -296,8 +298,8 @@ describe('draftFromRule', () => { const withWait = draftFromTemplate(AUTOMATION_TEMPLATES[0]); withWait.graph.timer = { mode: { afterMatch: { delayMs: 30_000 } } }; const draft = draftFromRule(withWait); - expect(draft.present).toEqual([...STEP_ORDER]); - expect(draft.wires).toEqual(defaultWires(STEP_ORDER, 'afterMatch')); + expect(draft.present).toEqual(WITH_TIMER); + expect(draft.wires).toEqual(defaultWires(WITH_TIMER, 'afterMatch')); expect(canAddStep(draft.present, 'timer')).not.toBeNull(); // And without one, the palette can still offer it. @@ -314,7 +316,7 @@ describe('draftFromRule', () => { const schedule = draftFromTemplate(AUTOMATION_TEMPLATES[0]); schedule.graph.timer = { mode: { dailyAt: { minuteOfDay: 540, days: 0b0001_1111 } } }; const draft = draftFromRule(schedule); - expect(draft.present).toEqual([...STEP_ORDER]); + expect(draft.present).toEqual(WITH_TIMER); expect(draft.wires.map((w) => `${w.from.step}.${w.from.port}->${w.to.step}.${w.to.port}`)) .toEqual(['timer.out->action.in']); }); @@ -714,8 +716,8 @@ describe('the reducer', () => { // The wait step drops in fifth wherever it is dropped, and re-draws the chain THROUGH it. d = draftReducer(d, { type: 'addStep', step: 'timer' }); - expect(d.present).toEqual([...STEP_ORDER]); - expect(d.wires).toEqual(defaultWires(STEP_ORDER, 'afterMatch')); + expect(d.present).toEqual(WITH_TIMER); + expect(d.wires).toEqual(defaultWires(WITH_TIMER, 'afterMatch')); }); /** @@ -730,7 +732,7 @@ describe('the reducer', () => { const rule = draftFromTemplate(AUTOMATION_TEMPLATES[0]); rule.graph.timer = { mode: { afterMatch: { delayMs: 30_000 } } }; const opened = draftFromRule(rule); - expect(opened.wires).toEqual(defaultWires(STEP_ORDER, 'afterMatch')); + expect(opened.wires).toEqual(defaultWires(WITH_TIMER, 'afterMatch')); const scheduled = draftReducer(opened, { type: 'timer', @@ -739,11 +741,11 @@ describe('the reducer', () => { expect(scheduled.rule.graph.timer).toEqual({ mode: { dailyAt: { minuteOfDay: 540, days: 0b0001_1111 } }, }); - expect(scheduled.wires).toEqual(defaultWires(STEP_ORDER, 'dailyAt')); + expect(scheduled.wires).toEqual(defaultWires(WITH_TIMER, 'dailyAt')); // And back, so this is a re-derivation rather than a one-way collapse. const back = draftReducer(scheduled, { type: 'timer', mode: { afterMatch: { delayMs: 5_000 } } }); - expect(back.wires).toEqual(defaultWires(STEP_ORDER, 'afterMatch')); + expect(back.wires).toEqual(defaultWires(WITH_TIMER, 'afterMatch')); }); /** diff --git a/src/renderer/components/Automation/__tests__/automationSteps.test.ts b/src/renderer/components/Automation/__tests__/automationSteps.test.ts index aa97b0c..3f8e8a9 100644 --- a/src/renderer/components/Automation/__tests__/automationSteps.test.ts +++ b/src/renderer/components/Automation/__tests__/automationSteps.test.ts @@ -1,12 +1,12 @@ /** - * §10.21 — the ordered-pair matrix over all five steps. + * §10.21 — the ordered-pair matrix over all six steps. * - * Total rather than sampled: nine ports, eighty-one ordered pairs, and exactly five are legal. A + * Total rather than sampled: ten ports, one hundred ordered pairs, and exactly seven are legal. A * test that checked "monitor connects to parse" and "parse does not connect to action" would pass on * a `canConnect` that had lost its type check entirely — the interesting refusals are the ones * nobody thinks to write down. * - * **The fifth step is `timer`, and it has two shapes rather than one** (plan 032 §6.2, §6.3), which + * **The timer has two shapes rather than one** (plan 032 §6.2, §6.3), which * is why `defaultWires` takes the shape as an argument and why this file asserts BOTH: in delay mode * the wait sits between the verdict and the send, and in schedule mode it *starts* the rule and * nothing else is wired at all. @@ -34,7 +34,7 @@ import { const key = (p: PortRef) => `${p.step}.${p.port}`; /** The four steps a rule had before the wait step existed — still a real shape, and the common one. */ -const WITHOUT_TIMER: StepKind[] = STEP_ORDER.filter((s) => s !== 'timer'); +const WITHOUT_TIMER: StepKind[] = STEP_ORDER.filter((s) => s !== 'timer' && s !== 'webhook'); /** * The wires SOME legal shape of a rule implies, and nothing else. @@ -45,7 +45,7 @@ const WITHOUT_TIMER: StepKind[] = STEP_ORDER.filter((s) => s !== 'timer'); * FAILS while the engine goes on firing when it succeeds. `wires` is session-only canvas state and * no behaviour derives from it, so the drawing was the only thing that changed. * - * **Five rather than three, because the wait step adds two and removes none.** `cond.true->action.in` + * **Seven rather than three, because the wait and webhook add four and remove none.** `cond.true->action.in` * stays legal: a rule with no wait step is still the ordinary rule, and refusing that pair would * un-draw every rule written before this milestone. */ @@ -55,12 +55,14 @@ const LEGAL = new Set([ 'cond.true->timer.in', 'cond.true->action.in', 'timer.out->action.in', + 'cond.true->webhook.in', + 'timer.out->webhook.in', ]); describe('canConnect — the ordered-pair matrix', () => { const ports = allPorts(); - it('has the nine ports the step table declares', () => { + it('has the ten ports the step table declares', () => { expect(ports.map(key)).toEqual([ 'monitor.out', 'parse.in', @@ -74,10 +76,11 @@ describe('canConnect — the ordered-pair matrix', () => { 'timer.in', 'timer.out', 'action.in', + 'webhook.in', ]); }); - it('accepts exactly five of the eighty-one ordered pairs', () => { + it('accepts exactly seven of the one hundred ordered pairs', () => { const accepted: string[] = []; for (const from of ports) { for (const to of ports) { @@ -87,7 +90,7 @@ describe('canConnect — the ordered-pair matrix', () => { } } expect(accepted.sort()).toEqual([...LEGAL].sort()); - expect(ports.length * ports.length).toBe(81); + expect(ports.length * ports.length).toBe(100); }); it('refuses each pair for the RIGHT reason', () => { @@ -116,6 +119,9 @@ describe('canConnect — the ordered-pair matrix', () => { expect(reason('parse.out', 'action.in')).toBe( `${STEP_LABELS.parse} sends a value, and ${STEP_LABELS.action} expects a yes or no.`, ); + expect(reason('parse.out', 'webhook.in')).toBe( + `${STEP_LABELS.parse} sends a value, and ${STEP_LABELS.webhook} expects a yes or no.`, + ); // Shape. The one refusal that is not about direction, self or type: `cond.false` is a real // output of the right type into a free input, and wiring it would draw the opposite of what // the rule does. The reason names the port that DOES drive the step, not the one tried. @@ -129,6 +135,10 @@ describe('canConnect — the ordered-pair matrix', () => { `${STEP_LABELS.timer} runs on ${STEP_LABELS.cond}'s yes output. The canvas draws the ` + 'rule, and a wire the rule would not follow is a picture of something else.', ); + expect(reason('cond.false', 'webhook.in')).toBe( + `${STEP_LABELS.webhook} runs on ${STEP_LABELS.cond}'s yes output. The canvas draws the ` + + 'rule, and a wire the rule would not follow is a picture of something else.', + ); // Type, on the new kind: the wait carries a verdict, so a value cannot enter it and the // lines a monitor prints cannot either. expect(reason('parse.out', 'timer.in')).toBe( @@ -136,6 +146,7 @@ describe('canConnect — the ordered-pair matrix', () => { ); // Direction, on the new kind: the wait's own input is not a source. expect(reason('timer.in', 'action.in')).toMatch(/output.*input/i); + expect(reason('timer.in', 'webhook.in')).toMatch(/output.*input/i); }); it('refuses a second wire into an input, BEFORE it asks about shape', () => { @@ -222,6 +233,11 @@ describe('canAddStep', () => { expect(refusal?.reason).toContain(STEP_LABELS.timer); }); + it('accepts the webhook after EITHER of the two steps that can drive it', () => { + expect(canAddStep(['timer'], 'webhook')).toBeNull(); + expect(canAddStep(['monitor', 'parse', 'cond'], 'webhook')).toBeNull(); + }); + /** * **R3 — the palette must not offer an add that validation will block with no way back.** * @@ -285,14 +301,15 @@ describe('defaultWires', () => { const drawn = (wires: ReturnType) => wires.map((w) => `${key(w.from)}->${key(w.to)}`); - it('wires the chain and leaves the `no` branch empty', () => { + it('wires both destinations directly from the verdict without a wait', () => { // The unused `no` port is how a user learns nothing happens on the other path — mockup §03 // draws it deliberately, so wiring it would be a design change, not a convenience. - const wires = defaultWires(WITHOUT_TIMER); + const wires = defaultWires(STEP_ORDER.filter((s) => s !== 'timer')); expect(drawn(wires)).toEqual([ 'monitor.out->parse.in', 'parse.out->cond.in', 'cond.true->action.in', + 'cond.true->webhook.in', ]); expect(wires.some((w) => w.from.port === 'false')).toBe(false); }); @@ -308,6 +325,7 @@ describe('defaultWires', () => { 'parse.out->cond.in', 'cond.true->timer.in', 'timer.out->action.in', + 'timer.out->webhook.in', ]); }); @@ -321,9 +339,13 @@ describe('defaultWires', () => { * interesting case is the rule that HAS a monitor (which `timer.scheduleWithMonitor` blocks), * not the tidy two-card one, because only the former can tell the two modes apart. */ - it('wires only the wait into the send in schedule mode, and nothing else', () => { - expect(drawn(defaultWires(STEP_ORDER, 'dailyAt'))).toEqual(['timer.out->action.in']); + it('wires the wait into both destinations in schedule mode, and nothing else', () => { + expect(drawn(defaultWires(STEP_ORDER, 'dailyAt'))).toEqual([ + 'timer.out->action.in', + 'timer.out->webhook.in', + ]); expect(drawn(defaultWires(['timer', 'action'], 'dailyAt'))).toEqual(['timer.out->action.in']); + expect(drawn(defaultWires(['timer', 'webhook'], 'dailyAt'))).toEqual(['timer.out->webhook.in']); }); it('produces only wires `canConnect` would accept, at every stage of building', () => { diff --git a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts index 6d096cc..404c192 100644 --- a/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts +++ b/src/renderer/components/Automation/__tests__/automationWritesWhatIsDrawn.test.ts @@ -26,6 +26,13 @@ const run = (draft: AutomationDraft, actions: DraftAction[]): AutomationDraft => const blockingCodes = (draft: AutomationDraft): string[] => blockingProblems(problems(ruleFromDraft(draft))).map((p) => p.code); +const webhook = { provider: 'discord' as const, url: 'https://hooks.example.invalid/canvas', body: 'done' }; + +const configuredWebhook = (draft: AutomationDraft): AutomationDraft => ({ + ...draft, + rule: { ...draft.rule, graph: { ...draft.rule.graph, webhook } }, +}); + describe('the editor writes the steps the canvas draws', () => { it('omits a hidden action scaffold from the graph a save serializes', () => { const draft = draftFromRule(blankDraft(), 'blank'); @@ -45,6 +52,74 @@ describe('the editor writes the steps the canvas draws', () => { expect(after.rule.graph).not.toHaveProperty('action'); }); + it('creates and reopens a webhook-only canvas without serializing the action scaffold', () => { + const created = configuredWebhook(run(draftFromRule(blankDraft(), 'blank'), [ + { type: 'addStep', step: 'monitor' }, + { type: 'addStep', step: 'parse' }, + { type: 'addStep', step: 'cond' }, + { type: 'addStep', step: 'webhook' }, + ])); + + const saved = JSON.parse(JSON.stringify(ruleFromDraft(created))); + expect(saved.graph.action).toBeUndefined(); + expect(saved.graph.webhook).toEqual(webhook); + + const reopened = draftFromRule(saved); + expect(reopened.present).toEqual(['monitor', 'parse', 'cond', 'webhook']); + expect(reopened.wires).toEqual([ + { from: { step: 'monitor', port: 'out' }, to: { step: 'parse', port: 'in' } }, + { from: { step: 'parse', port: 'out' }, to: { step: 'cond', port: 'in' } }, + { from: { step: 'cond', port: 'true' }, to: { step: 'webhook', port: 'in' } }, + ]); + }); + + it('adds a terminal send beside a webhook destination and reopens both', () => { + const withBoth = configuredWebhook(run(draftFromRule(blankDraft(), 'blank'), [ + { type: 'addStep', step: 'monitor' }, + { type: 'addStep', step: 'parse' }, + { type: 'addStep', step: 'cond' }, + { type: 'addStep', step: 'webhook' }, + { type: 'addStep', step: 'action' }, + { type: 'action', patch: { message: 'send this too' } }, + ])); + + const reopened = draftFromRule(JSON.parse(JSON.stringify(ruleFromDraft(withBoth)))); + expect(reopened.present).toEqual(['monitor', 'parse', 'cond', 'action', 'webhook']); + expect(reopened.wires).toEqual([ + { from: { step: 'monitor', port: 'out' }, to: { step: 'parse', port: 'in' } }, + { from: { step: 'parse', port: 'out' }, to: { step: 'cond', port: 'in' } }, + { from: { step: 'cond', port: 'true' }, to: { step: 'action', port: 'in' } }, + { from: { step: 'cond', port: 'true' }, to: { step: 'webhook', port: 'in' } }, + ]); + }); + + it('removes either destination through its wire chip without retaining an action scaffold', () => { + const withBoth = configuredWebhook(run(draftFromRule(blankDraft(), 'blank'), [ + { type: 'addStep', step: 'monitor' }, + { type: 'addStep', step: 'parse' }, + { type: 'addStep', step: 'cond' }, + { type: 'addStep', step: 'action' }, + { type: 'action', patch: { message: 'send this too' } }, + { type: 'addStep', step: 'webhook' }, + ])); + + const withoutTerminal = draftReducer(withBoth, { + type: 'removeWire', + wire: { from: { step: 'cond', port: 'true' }, to: { step: 'action', port: 'in' } }, + }); + expect(withoutTerminal.present).toEqual(['monitor', 'parse', 'cond', 'webhook']); + expect(ruleFromDraft(withoutTerminal).graph.action).toBeUndefined(); + expect(ruleFromDraft(withoutTerminal).graph.webhook).toEqual(webhook); + + const withoutWebhook = draftReducer(withBoth, { + type: 'removeWire', + wire: { from: { step: 'cond', port: 'true' }, to: { step: 'webhook', port: 'in' } }, + }); + expect(withoutWebhook.present).toEqual(['monitor', 'parse', 'cond', 'action']); + expect(ruleFromDraft(withoutWebhook).graph.webhook).toBeUndefined(); + expect(ruleFromDraft(withoutWebhook).graph.action?.message).toBe('send this too'); + }); + /** * **C1, and the one assertion whose absence hid it.** Built the way the palette builds it — * `addStep` for each card, then the Wait panel's own mode dispatch — and asked of diff --git a/src/renderer/components/Automation/automationDerive.ts b/src/renderer/components/Automation/automationDerive.ts index a656a73..c899c53 100644 --- a/src/renderer/components/Automation/automationDerive.ts +++ b/src/renderer/components/Automation/automationDerive.ts @@ -52,6 +52,8 @@ export const STEP_FIELDS: Record = { cond: ['cond'], timer: ['timer'], action: ['action'], + // Webhook validation is still owned by the destination field until Task 12 adds its panel. + webhook: [], }; export interface DeriveContext { @@ -268,7 +270,7 @@ export function describeDelay(ms: number): string { * face that silently picked `values[1]` would break the moment a row moved. */ export function stepValues(rule: AutomationRule, step: StepKind): Record { - const { monitor, parse, cond, timer, action } = rule.graph; + const { monitor, parse, cond, timer, action, webhook } = rule.graph; switch (step) { case 'monitor': return { @@ -361,6 +363,8 @@ export function stepValues(rule: AutomationRule, step: StepKind): Record> = { { label: 'Send', key: 'message' }, { label: 'Then', key: 'send' }, ], + webhook: [ + // The endpoint is a credential. Provider is the only webhook configuration safe to show + // on the canvas; Task 12 owns its editable inspector. + { label: 'Post', key: 'provider' }, + ], }; /** The problems belonging to a step — both categories, for the monitor. */ @@ -480,9 +489,8 @@ export type NodeTone = 'error' | 'warn' | 'live' | 'ready' | 'absent'; /** * Does the rule actually HAVE this step? (plan 032 §3.1.) * - * `action` is the one step no rule can be without — a rule with nothing to send is not a rule — so - * it is the only kind this answers `true` for unconditionally. The other three are optional on the - * DTO. Since task 29 `draftFromRule` draws only the steps a rule HAS, so the editor no longer opens + * Every step is optional on the DTO: a rule needs at least one destination, not necessarily a + * terminal send. Since task 29 `draftFromRule` draws only the steps a rule HAS, so the editor no longer opens * a schedule rule on three cards standing for steps it does not have — but this answer is still * load-bearing for every other reader of a graph, the test pane included, and for a row that reached * the store by some other route. diff --git a/src/renderer/components/Automation/automationDraft.ts b/src/renderer/components/Automation/automationDraft.ts index 124c391..68500fd 100644 --- a/src/renderer/components/Automation/automationDraft.ts +++ b/src/renderer/components/Automation/automationDraft.ts @@ -99,6 +99,7 @@ export const DEFAULT_LAYOUT: Record = { // `draft.present`, not a reason to give one kind two default positions. timer: { x: AU_GAP_X * 3, y: 0 }, action: { x: AU_GAP_X * 4, y: 0 }, + webhook: { x: AU_GAP_X * 5, y: 0 }, }; /** @@ -438,14 +439,23 @@ function graphAsWritten( ): AutomationRule['graph'] { const keepInput = INPUT_STEPS.some((s) => present.includes(s)); const keepAction = present.includes('action'); - if (keepInput && keepAction) return graph; + const keepWebhook = present.includes('webhook'); + const graphHasInput = INPUT_STEPS.some((step) => step in graph); + // Preserve the graph object whenever it already says exactly what the canvas says. Besides + // avoiding churn, that preserves property order for the dirty check's wire-shaped baseline. + if ( + (keepInput || !graphHasInput) + && (keepAction || !('action' in graph)) + && (keepWebhook || !('webhook' in graph)) + ) return graph; // The KEYS go, not `undefined` values. In particular, an action scaffold hidden by the canvas // must not survive serialisation and submit an Enter in a supposedly webhook-only rule. - const { monitor, parse, cond, action, ...rest } = graph; + const { monitor, parse, cond, action, webhook, ...rest } = graph; return { ...rest, ...(keepInput ? { monitor, parse, cond } : {}), ...(keepAction ? { action } : {}), + ...(keepWebhook ? { webhook } : {}), }; } @@ -524,8 +534,8 @@ function layoutOf(rule: AutomationRule): Record { * second one: any canvas keeping one input step keeps all three, and `parse.empty` goes on catching * the partial cases. * - * `action` is never omitted — §3.1 keeps it required on the DTO — and `timer` needs no rule here, - * because `addStep` materialises it into the graph exactly when the canvas reveals it. + * The two destinations are omitted independently when their cards are absent; `timer` needs no rule + * here, because `addStep` materialises it into the graph exactly when the canvas reveals it. * * **`op`/`threshold` are dropped from the row that carries clauses, and only from that row.** §5.3 * makes the pair v1-only — read at load, folded into `clauses` by `fold_v1_clauses`, never written @@ -731,6 +741,11 @@ function materialise(rule: AutomationRule, step: StepKind): AutomationRule { const action = rule.graph.action ?? blankDraft().graph.action; return action ? { ...rule, graph: { ...rule.graph, action } } : rule; } + if (step === 'webhook') { + return rule.graph.webhook == null + ? { ...rule, graph: { ...rule.graph, webhook: { provider: 'discord', url: '', body: '' } } } + : rule; + } if (INPUT_STEPS.every((s) => rule.graph[s] != null)) return rule; const blank = blankDraft().graph; return { @@ -886,6 +901,22 @@ export function draftReducer(draft: AutomationDraft, action: DraftAction): Autom case 'addWire': return { ...draft, wires: [...draft.wires, action.wire] }; case 'removeWire': + // A destination card has exactly one incoming wire, so its wire chip is its remove + // gesture. Removing it must remove the destination too: merely hiding the card while + // retaining `action` would leave a live terminal send on a webhook-only canvas. + if (action.wire.to.step === 'action' || action.wire.to.step === 'webhook') { + const destination = action.wire.to.step; + const { [destination]: _removed, ...graph } = rule.graph; + const present = draft.present.filter((step) => step !== destination); + const next = { ...rule, graph }; + return { + ...draft, + rule: next, + present, + wires: defaultWires(present, timerShapeOf(next)), + selected: draft.selected === destination ? null : draft.selected, + }; + } return { ...draft, wires: draft.wires.filter( diff --git a/src/renderer/components/Automation/automationSteps.ts b/src/renderer/components/Automation/automationSteps.ts index 90d1616..ab2f9d2 100644 --- a/src/renderer/components/Automation/automationSteps.ts +++ b/src/renderer/components/Automation/automationSteps.ts @@ -1,5 +1,5 @@ /** - * The five steps, their typed ports, and the one function that decides whether a wire is legal + * The six steps, their typed ports, and the one function that decides whether a wire is legal * (plan 028 §6.3, plan 032 §6.2/§6.3/§9, mockup §03). * * **Canvas Mode's ports are undirected 4-compass geometry with no type system**, and the only @@ -8,7 +8,7 @@ * §10.21's ordered-pair matrix a cheap and total test rather than a sampling of the interesting * cases. * - * **The five step KINDS are fixed; a rule having all five is not.** This file's arithmetic is over + * **The six step KINDS are fixed; a rule having all six is not.** This file's arithmetic is over * the names — the order, the ports, which pairs may be wired — and none of it reads `rule.graph`. * That is what keeps it true while the DTO changes underneath it: plan 032 §3.1 made `monitor`, * `parse` and `cond` optional, so a schedule rule (§6.3) is `action` and a `timer` and nothing else. @@ -31,7 +31,7 @@ * — but no longer by every schedule rule on open. */ -export type StepKind = 'monitor' | 'parse' | 'cond' | 'timer' | 'action'; +export type StepKind = 'monitor' | 'parse' | 'cond' | 'timer' | 'action' | 'webhook'; /** * Left to right, and also the order the palette lists them and the problem list reports them. @@ -48,6 +48,7 @@ export const STEP_ORDER: readonly StepKind[] = Object.freeze([ 'cond', 'timer', 'action', + 'webhook', ]); /** @@ -84,6 +85,7 @@ export const STEP_LABELS: Record = { cond: 'Compare it', timer: 'Wait', action: 'Send to terminal', + webhook: 'Send to webhook', }; /** The line under the title in the inspector head — *Step 2 · find text, pull a number out*. */ @@ -93,6 +95,7 @@ export const STEP_SUBTITLES: Record = { cond: 'decide yes or no', timer: 'hold, or fire on the clock', action: 'what happens when it fires', + webhook: 'what happens when it fires', }; /** What travels on a wire. Three types, and only equal types connect. */ @@ -150,6 +153,7 @@ const PORTS = { { id: 'out', dir: 'out', type: 'verdict', label: 'go' }, ] as const), action: Object.freeze([{ id: 'in', dir: 'in', type: 'verdict', label: 'verdict' }] as const), + webhook: Object.freeze([{ id: 'in', dir: 'in', type: 'verdict', label: 'verdict' }] as const), } as const; export const STEP_PORTS: Record = PORTS; @@ -266,8 +270,8 @@ export function canConnect( /** * Which step must already be on the canvas before this one makes sense — **any ONE of them**. * - * A list rather than a single kind, because `action` has two drivers and always did in principle: - * the verdict from `Compare it` on a watching rule, and the wait itself on a schedule rule, which + * A list rather than a single kind, because either destination has two drivers in principle: the + * verdict from `Compare it` on a watching rule, and the wait itself on a schedule rule, which * has no comparison and is not allowed one (§6.3). Named as one predecessor, the palette demanded a * `Compare it` that a schedule rule must not carry, so *"a wait and a send"* — the whole of mockup * §03's rule — could not be built at all. @@ -280,6 +284,7 @@ const REQUIRES: Partial> = { parse: ['monitor'], cond: ['parse'], action: ['cond', 'timer'], + webhook: ['cond', 'timer'], }; /** @@ -399,6 +404,9 @@ export function defaultWires( if (has('action')) { out.push({ from: { step: 'timer', port: 'out' }, to: { step: 'action', port: 'in' } }); } + if (has('webhook')) { + out.push({ from: { step: 'timer', port: 'out' }, to: { step: 'webhook', port: 'in' } }); + } return out; } @@ -416,6 +424,11 @@ export function defaultWires( } else if (has('cond') && has('action')) { out.push({ from: { step: 'cond', port: 'true' }, to: { step: 'action', port: 'in' } }); } + if (has('timer') && has('webhook')) { + out.push({ from: { step: 'timer', port: 'out' }, to: { step: 'webhook', port: 'in' } }); + } else if (has('cond') && has('webhook')) { + out.push({ from: { step: 'cond', port: 'true' }, to: { step: 'webhook', port: 'in' } }); + } return out; } From 251b122f3cb08b3c9bb51c410a87d5349f7b5d47 Mon Sep 17 00:00:00 2001 From: Tam Tran Date: Sun, 6 Sep 2026 18:43:02 -0500 Subject: [PATCH 14/36] feat(automation): add webhook inspector --- src-tauri/src/automation_validation.rs | 32 +++- .../components/Automation/AuInspector.tsx | 7 +- .../Automation/AutomationEditor.css | 16 ++ .../automationValidationCases.json | 25 ++- .../__tests__/webhookPanel.test.tsx | 130 ++++++++++++++ .../components/Automation/automationDraft.ts | 7 + .../Automation/automationValidation.ts | 24 +-- .../Automation/panels/WebhookPanel.tsx | 170 ++++++++++++++++++ 8 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 src/renderer/components/Automation/__tests__/webhookPanel.test.tsx create mode 100644 src/renderer/components/Automation/panels/WebhookPanel.tsx diff --git a/src-tauri/src/automation_validation.rs b/src-tauri/src/automation_validation.rs index 8144c34..2526379 100644 --- a/src-tauri/src/automation_validation.rs +++ b/src-tauri/src/automation_validation.rs @@ -737,7 +737,21 @@ pub fn problems(rule: &AutomationRule) -> Vec { // toggle claims the message inserts a capture, and a rule with no parse step at all captures // nothing, exactly like one whose pattern is still empty. `parse_step` is what makes the two // spellings indistinguishable to this check. - if let Some(action) = rule.graph.action.as_ref().filter(|action| action.substitute) { + for (field, message) in [ + rule.graph + .action + .as_ref() + .filter(|action| action.substitute) + .map(|action| ("action", action.message.as_str())), + rule.graph + .webhook + .as_ref() + .filter(|webhook| webhook.substitute) + .map(|webhook| ("webhook", webhook.body.as_str())), + ] + .into_iter() + .flatten() + { match parse_step(&rule.graph) { // The toggle itself claims the message inserts a capture, which nothing can be true // of before a pattern exists — asked regardless of whether a token has actually been @@ -745,14 +759,14 @@ pub fn problems(rule: &AutomationRule) -> Vec { // clause would compare against. None => out.push(Problem::new( Severity::Blocks, - "action", + field, "action.tokenWithoutParse", "This message inserts captured values, but the rule has no pattern to capture them from.", )), Some(parse) => { if let Ok(compiled) = compile(&parse.find) { let count = compiled.captures_len().saturating_sub(1); - for token in subst::tokens_used(&action.message) { + for token in subst::tokens_used(message) { let bad = match &token { subst::Token::Whole => false, subst::Token::Group(n) => !token_supplied(&compiled, Some(*n), None), @@ -765,7 +779,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { } out.push(Problem::new( Severity::Blocks, - "action", + field, "action.unknownToken", format!( "{token} has nothing to stand for. The pattern in Read a value has \ @@ -784,7 +798,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { if webhook.url.trim().is_empty() { out.push(Problem::new( Severity::Blocks, - "action", + "webhook", "webhook.urlEmpty", "Provide a webhook URL.", )); @@ -792,7 +806,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { if url.scheme() != "https" { out.push(Problem::new( Severity::Blocks, - "action", + "webhook", "webhook.urlNotHttps", "Provide an https webhook URL.", )); @@ -800,7 +814,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { } else { out.push(Problem::new( Severity::Blocks, - "action", + "webhook", "webhook.urlMalformed", "Provide a well-formed webhook URL.", )); @@ -809,7 +823,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { if webhook.body.trim().is_empty() { out.push(Problem::new( Severity::Blocks, - "action", + "webhook", "webhook.bodyEmpty", "Enter a webhook body.", )); @@ -818,7 +832,7 @@ pub fn problems(rule: &AutomationRule) -> Vec { if serde_json::from_str::(&rendered_body).is_err() { out.push(Problem::new( Severity::Blocks, - "action", + "webhook", "webhook.bodyNotJson", "The webhook body must be valid JSON.", )); diff --git a/src/renderer/components/Automation/AuInspector.tsx b/src/renderer/components/Automation/AuInspector.tsx index d807a46..3a89b90 100644 --- a/src/renderer/components/Automation/AuInspector.tsx +++ b/src/renderer/components/Automation/AuInspector.tsx @@ -21,6 +21,7 @@ import { ParsePanel } from './panels/ParsePanel'; import { CondPanel } from './panels/CondPanel'; import { TimerPanel } from './panels/TimerPanel'; import { ActionPanel } from './panels/ActionPanel'; +import { WebhookPanel } from './panels/WebhookPanel'; import { redactWebhookText } from './webhookRedaction'; export interface AuInspectorProps { @@ -62,6 +63,7 @@ const FIELD_STEPS: Record = { cond: 'cond', timer: 'timer', action: 'action', + webhook: 'webhook', }; export const AuInspector: React.FC = (props) => { @@ -137,6 +139,9 @@ export const AuInspector: React.FC = (props) => { {step === 'action' && draft.rule.graph.action && ( )} + {step === 'webhook' && draft.rule.graph.webhook && ( + + )}
    ); @@ -159,7 +164,7 @@ const ProblemList: React.FC<{ problems: Problem[]; onFocusStep: (step: StepKind)
      {problems.map((p) => ( -
    • +
    • diff --git a/src/renderer/components/Automation/AutomationEditor.css b/src/renderer/components/Automation/AutomationEditor.css index c169005..3a6659c 100644 --- a/src/renderer/components/Automation/AutomationEditor.css +++ b/src/renderer/components/Automation/AutomationEditor.css @@ -1040,6 +1040,22 @@ gap: 7px; } +.au-editor .au-frow .au-mini { + flex: 0 0 auto; + padding: 6px 10px; + font-size: 0.85rem; + color: #a9adb6; + background: #1c1d22; + border: 1px solid #34363e; + border-radius: 7px; + cursor: pointer; +} + +.au-editor .au-frow .au-mini:hover { + color: #fff; + background: #2c2f37; +} + .au-editor .au-radio, .au-editor .au-checkrow { display: flex; diff --git a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json index 4dd31b1..2f0ad78 100644 --- a/src/renderer/components/Automation/__fixtures__/automationValidationCases.json +++ b/src/renderer/components/Automation/__fixtures__/automationValidationCases.json @@ -1728,8 +1728,8 @@ "expected": [] }, { - "name": "action.unknownToken — $3 with two groups", - "why": "Two groups exist, but the message asks for a third. `substitute` is opt-in (plan 032 §4.2), so a text rule can carry this pattern and message with the toggle off with nothing wrong — the case right after this one.", + "name": "action.unknownToken — each destination rejects $3 with two groups", + "why": "Two groups exist, but both destination messages ask for a third. The terminal action and webhook reuse the same token rule, while retaining their own inspector ownership.", "rule": { "id": "au-fix", "name": "r", @@ -1769,6 +1769,12 @@ "submit": true, "cliType": "default", "substitute": true + }, + "webhook": { + "provider": "discord", + "url": "https://example.invalid/hook", + "body": "post $3", + "substitute": true } } }, @@ -1777,6 +1783,11 @@ "severity": "blocks", "field": "action", "code": "action.unknownToken" + }, + { + "severity": "blocks", + "field": "webhook", + "code": "action.unknownToken" } ] }, @@ -3408,7 +3419,7 @@ "expected": [ { "severity": "blocks", - "field": "action", + "field": "webhook", "code": "webhook.urlEmpty" } ] @@ -3443,7 +3454,7 @@ "expected": [ { "severity": "blocks", - "field": "action", + "field": "webhook", "code": "webhook.urlMalformed" } ] @@ -3478,7 +3489,7 @@ "expected": [ { "severity": "blocks", - "field": "action", + "field": "webhook", "code": "webhook.urlNotHttps" } ] @@ -3513,7 +3524,7 @@ "expected": [ { "severity": "blocks", - "field": "action", + "field": "webhook", "code": "webhook.bodyEmpty" } ] @@ -3555,7 +3566,7 @@ "expected": [ { "severity": "blocks", - "field": "action", + "field": "webhook", "code": "webhook.bodyNotJson" } ] diff --git a/src/renderer/components/Automation/__tests__/webhookPanel.test.tsx b/src/renderer/components/Automation/__tests__/webhookPanel.test.tsx new file mode 100644 index 0000000..4bb65d1 --- /dev/null +++ b/src/renderer/components/Automation/__tests__/webhookPanel.test.tsx @@ -0,0 +1,130 @@ +/** @jest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, Root } from 'react-dom/client'; + +import { AuInspector } from '../AuInspector'; +import { draftFromRule } from '../automationDraft'; +import { problems } from '../automationValidation'; +import type { AutomationRule, AutomationWebhookProvider } from '../../../types/electron'; +import { blankDraft } from '../../Settings/Automations/automationTemplates'; + +const SECRET_A = 'https://hooks.example.invalid/secret-a'; +const SECRET_B = 'https://hooks.example.invalid/secret-b'; + +function ruleWithWebhook( + id: string, + provider: AutomationWebhookProvider = 'discord', + body = 'build failed', +): AutomationRule { + const blank = blankDraft(); + const { action: _action, ...graph } = blank.graph; + return { + ...blank, + id, + graph: { + ...graph, + webhook: { provider, url: id === 'au-b' ? SECRET_B : SECRET_A, body }, + }, + }; +} + +describe('the webhook inspector', () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + async function show(rule: AutomationRule) { + const draft = { ...draftFromRule(rule), selected: 'webhook' as const }; + await act(async () => { + root.render( + {}} + onFocusStep={() => {}} + dispatch={() => {}} + />, + ); + }); + } + + const urlInput = () => container.querySelector('[aria-label="Webhook URL"]')!; + const preview = () => container.querySelector('[data-testid="webhook-preview"]')!; + + it('masks the URL until Reveal is pressed', async () => { + await show(ruleWithWebhook('au-a')); + expect(urlInput().type).toBe('password'); + expect(container.textContent).not.toContain(SECRET_A); + + await act(async () => { + [...container.querySelectorAll('button')].find((button) => button.textContent === 'Reveal')!.click(); + }); + expect(urlInput().type).toBe('text'); + + // A different saved rule replaces the panel, so its credential cannot inherit Reveal. + await show(ruleWithWebhook('au-b')); + expect(urlInput().type).toBe('password'); + expect(container.textContent).not.toContain(SECRET_A); + expect(container.textContent).not.toContain(SECRET_B); + }); + + it('never renders the URL in the preview', async () => { + const expected: Array<[AutomationWebhookProvider, string, string]> = [ + ['discord', 'build failed', '{"content":"build failed"}'], + ['slack', 'build failed', '{"text":"build failed"}'], + ['teams', 'build failed', '{"@context":"http://schema.org/extensions","@type":"MessageCard","text":"build failed"}'], + ['custom', '{ "kind": "custom" }', '{ "kind": "custom" }'], + ]; + + for (const [provider, body, payload] of expected) { + await show(ruleWithWebhook('au-a', provider, body)); + expect(preview().textContent).toBe(payload); + expect(preview().textContent).not.toContain(SECRET_A); + expect(container.textContent).not.toContain(SECRET_A); + for (const element of container.querySelectorAll('[title], [aria-label]')) { + expect(element.getAttribute('title') ?? '').not.toContain(SECRET_A); + expect(element.getAttribute('aria-label') ?? '').not.toContain(SECRET_A); + } + expect([...container.querySelectorAll('button')].some((button) => /copy/i.test(button.textContent ?? ''))).toBe(false); + } + }); + + it('refuses a capture token the pattern cannot supply', async () => { + const rule = ruleWithWebhook('au-a'); + const withPattern: AutomationRule = { + ...rule, + graph: { + ...rule.graph, + parse: { preset: 'custom', literal: null, find: 'FAILED (\\d+)', keep: 'brackets' }, + webhook: { ...rule.graph.webhook!, body: 'post $2', substitute: true }, + }, + }; + expect(problems(withPattern)).toContainEqual(expect.objectContaining({ + field: 'webhook', + code: 'action.unknownToken', + })); + + await show(withPattern); + expect(container.textContent).toContain('$2 has nothing to stand for.'); + }); +}); diff --git a/src/renderer/components/Automation/automationDraft.ts b/src/renderer/components/Automation/automationDraft.ts index 68500fd..5ec776b 100644 --- a/src/renderer/components/Automation/automationDraft.ts +++ b/src/renderer/components/Automation/automationDraft.ts @@ -658,6 +658,7 @@ export type DraftAction = */ | { type: 'timer'; mode: AutomationTimerMode } | { type: 'action'; patch: Partial> } + | { type: 'webhook'; patch: Partial> } | { type: 'select'; step: StepKind | null } | { type: 'addStep'; step: StepKind } | { type: 'moveStep'; step: StepKind; pos: NodePos } @@ -860,6 +861,12 @@ export function draftReducer(draft: AutomationDraft, action: DraftAction): Autom return rule.graph.action ? withGraph(draft, { action: { ...rule.graph.action, ...action.patch } }) : draft; + case 'webhook': + // A panel patch cannot materialise a destination. In particular, it must never make + // an ActionStep: an empty action can still submit Enter to a live terminal. + return rule.graph.webhook + ? withGraph(draft, { webhook: { ...rule.graph.webhook, ...action.patch } }) + : draft; case 'select': return { ...draft, selected: action.step }; case 'addStep': { diff --git a/src/renderer/components/Automation/automationValidation.ts b/src/renderer/components/Automation/automationValidation.ts index 417ed1b..5cb0c52 100644 --- a/src/renderer/components/Automation/automationValidation.ts +++ b/src/renderer/components/Automation/automationValidation.ts @@ -38,7 +38,7 @@ import { previewSubstitute, tokensUsed } from './automationTokens'; export type Severity = 'blocks' | 'warns'; /** Which step owns a problem, so the editor can point at the panel that fixes it. */ -export type ProblemField = 'targets' | 'monitor' | 'parse' | 'cond' | 'timer' | 'action'; +export type ProblemField = 'targets' | 'monitor' | 'parse' | 'cond' | 'timer' | 'action' | 'webhook'; /** * A stable identity for the RULE that fired. @@ -807,7 +807,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + 'webhook', 'webhook.urlEmpty', 'Provide a webhook URL.', ), @@ -819,7 +819,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + 'webhook', 'webhook.urlNotHttps', 'Provide an https webhook URL.', ), @@ -829,7 +829,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + 'webhook', 'webhook.urlMalformed', 'Provide a well-formed webhook URL.', ), @@ -841,7 +841,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + 'webhook', 'webhook.bodyEmpty', 'Enter a webhook body.', ), @@ -854,7 +854,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + 'webhook', 'webhook.bodyNotJson', 'The webhook body must be valid JSON.', ), @@ -876,7 +876,11 @@ export function problems(rule: AutomationRule): Problem[] { // toggle claims the message inserts a capture, and a rule with no parse step at all captures // nothing, exactly like one whose pattern is still empty. `parseStep` is what makes the two // spellings indistinguishable to this check. - if (action?.substitute) { + for (const destination of [ + action?.substitute ? { field: 'action' as const, message: action.message } : null, + webhook?.substitute ? { field: 'webhook' as const, message: webhook.body } : null, + ]) { + if (!destination) continue; const sourcing = parseStep(rule.graph); if (!sourcing) { // The toggle itself claims the message inserts a capture, which nothing can be true of @@ -886,14 +890,14 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + destination.field, 'action.tokenWithoutParse', 'This message inserts captured values, but the rule has no pattern to capture them from.', ), ); } else if (compilePattern(sourcing.find) !== null) { const groups = groupsOf(sourcing.find); - for (const t of tokensUsed(action.message)) { + for (const t of tokensUsed(destination.message)) { const bad = t.kind === 'group' ? !tokenSupplied(groups, t.n, null) : !tokenSupplied(groups, null, t.name); @@ -901,7 +905,7 @@ export function problems(rule: AutomationRule): Problem[] { out.push( problem( 'blocks', - 'action', + destination.field, 'action.unknownToken', `${t.text} has nothing to stand for. The pattern in Read a value has ` + `${groups.count} bracketed group${groups.count === 1 ? '' : 's'}, so the highest you can use is $${groups.count}.`, diff --git a/src/renderer/components/Automation/panels/WebhookPanel.tsx b/src/renderer/components/Automation/panels/WebhookPanel.tsx new file mode 100644 index 0000000..c14e9af --- /dev/null +++ b/src/renderer/components/Automation/panels/WebhookPanel.tsx @@ -0,0 +1,170 @@ +/** + * The webhook destination inspector. + * + * The endpoint is a credential, not rule prose. It is bound only to a password input and becomes + * visible only after this panel's explicit Reveal action. The request preview deliberately models + * the BODY the sender posts, never the endpoint it posts to. + */ +import React from 'react'; +import type { AutomationWebhookProvider } from '../../../types/electron'; +import type { AutomationDraft, DraftAction } from '../automationDraft'; +import { compilePattern, groupsOf } from '../automationValidation'; +import { previewSubstitute } from '../automationTokens'; +import { sampleFromPattern } from './ActionPanel'; +import { AuCheck, AuField, AuHelp } from './AuFields'; + +export interface WebhookPanelProps { + draft: AutomationDraft; + dispatch: (action: DraftAction) => void; +} + +type ChipInfo = { text: string; dead: boolean }; + +const groupToken = (n: number): string => (n < 10 ? `$${n}` : `\${${n}}`); + +/** + * The renderer's mirror of `automation_webhook.rs`'s private `payload` function. + * + * Rust remains the sender authority. This shapes exactly its provider wrappers for an on-screen + * body preview; Custom preserves the text verbatim just as the sender preserves its bytes. + */ +export function previewWebhookPayload(provider: AutomationWebhookProvider, message: string): string { + switch (provider) { + case 'discord': + return JSON.stringify({ content: message }); + case 'slack': + return JSON.stringify({ text: message }); + case 'teams': + // serde_json's default map ordering writes these keys in this order too. + return JSON.stringify({ + '@context': 'http://schema.org/extensions', + '@type': 'MessageCard', + text: message, + }); + case 'custom': + return message; + } +} + +export const WebhookPanel: React.FC = ({ draft, dispatch }) => { + const { parse, webhook } = draft.rule.graph; + const bodyRef = React.useRef(null); + const [revealed, setRevealed] = React.useState(false); + if (!webhook) return null; + + const find = parse?.find ?? ''; + const patternReady = compilePattern(find) !== null && find.trim().length > 0; + const groups = patternReady ? groupsOf(find) : { count: 0, names: new Set() }; + const substitute = webhook.substitute === true; + const sample = parse ? sampleFromPattern(parse.find, parse.keep) : null; + const rendered = substitute && patternReady + ? previewSubstitute(webhook.body, groups, sample) + : null; + const previewMessage = rendered && rendered.ok + ? rendered.parts.map((part) => part.kind === 'text' ? part.text : `⟨${part.token}⟩`).join('') + : webhook.body; + const blocked = substitute && (!patternReady || (rendered !== null && !rendered.ok)); + const blockedText = !patternReady + ? 'Nothing would be posted — there is no pattern yet to capture values from.' + : rendered && !rendered.ok + ? `Nothing would be posted — ${rendered.badToken} has nothing to stand for.` + : null; + const preview = previewWebhookPayload(webhook.provider, previewMessage); + const chips: ChipInfo[] = [ + { text: '$0', dead: !patternReady }, + ...Array.from({ length: groups.count }, (_, i): ChipInfo => ({ text: groupToken(i + 1), dead: false })), + { text: groupToken(groups.count + 1), dead: true }, + { text: '$$', dead: false }, + ]; + const body = webhook.body; + + function insertToken(token: string) { + const el = bodyRef.current; + const value = body; + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + dispatch({ type: 'webhook', patch: { body: value.slice(0, start) + token + value.slice(end) } }); + } + + return ( + <> + + + + + +
      + dispatch({ type: 'webhook', patch: { url: e.target.value } })} + /> + +
      + + This URL is a password. Anyone holding it can post into that channel as this integration. + +
      + + +