Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ae51850
feat(automation): persist per-rule terminal exclusions
tamtranthien Sep 6, 2026
0cb7855
feat(automation): subtract excluded terminals in the targeting gate
tamtranthien Sep 6, 2026
7fcd753
feat(automation): preview resolved target ids
tamtranthien Sep 6, 2026
730f9fb
feat(automation): validate exclusion criteria
tamtranthien Sep 6, 2026
844d8a6
feat(automation): add exclusions panel
tamtranthien Sep 6, 2026
757222a
fix(automation): scan exclusion criteria
tamtranthien Sep 6, 2026
e048846
feat(automation): add webhook graph destination
tamtranthien Sep 6, 2026
be13391
test(automation): cover absent action paths
tamtranthien Sep 6, 2026
c6d1482
feat(automation): add webhook sender
tamtranthien Sep 6, 2026
d0eda1c
feat(automation): dispatch webhook destinations
tamtranthien Sep 6, 2026
9dac41f
fix(automation): redact webhook endpoints
tamtranthien Sep 6, 2026
fa99b03
feat(automation): validate webhook rules
tamtranthien Sep 6, 2026
93b50d2
feat(automation): draw webhook destinations on canvas
tamtranthien Sep 6, 2026
251b122
feat(automation): add webhook inspector
tamtranthien Sep 6, 2026
3d17f88
test(automation): share webhook payload fixtures
tamtranthien Sep 6, 2026
0b886b2
fix(automation): block blank terminal actions with webhooks
tamtranthien Sep 7, 2026
7896831
fix(automation): show webhooks in dry runs
tamtranthien Sep 7, 2026
688d66f
test(automation): cover webhook payload escaping
tamtranthien Sep 7, 2026
d40e59e
test(automation): round-trip the v1 graph shape
tamtranthien Sep 7, 2026
200c045
feat(automation): add wait and webhook templates
tamtranthien Sep 7, 2026
d8927c5
test(automation): say what the template variety test pins
tamtranthien Sep 7, 2026
f8105ca
fix(automation): give the webhook step an accent, drop the drawer tab
tamtranthien Sep 7, 2026
66dcc94
fix(ui): keep toasts out of the automation editor header
tamtranthien Sep 7, 2026
f1183c6
fix(automation): centre radio and checkbox marks on their title line
tamtranthien Sep 7, 2026
f874d19
fix(automation): separate the token chips from the input above them
tamtranthien Sep 7, 2026
0680e68
fix(automation): do not claim nothing matches a rule not yet resolved
tamtranthien Sep 7, 2026
1db7a07
fix(automation): a dropdown the window edge cannot cut off
tamtranthien Sep 7, 2026
f323816
feat(automation): delete a step from the canvas
tamtranthien Sep 7, 2026
d52a282
fix(canvas): a destructive menu row you can read
tamtranthien Sep 7, 2026
86b9540
fix(automation): a token chip that actually substitutes
tamtranthien Sep 7, 2026
a7bf1be
feat(automation): insert captured values on by default
tamtranthien Sep 7, 2026
338ce9e
style: one scrollbar rule for the whole app
tamtranthien Sep 7, 2026
592a00f
feat(automation): a settings panel you can size and put away
tamtranthien Sep 7, 2026
54a26d9
feat(automation): a rule can ignore the line being typed
tamtranthien Sep 7, 2026
d2fdad0
fix(automation): inspector chrome overlaps, an unclipped flyout, exam…
tamtranthien Sep 7, 2026
3a36939
fix(automation): the collapse button moves right, and two things line up
tamtranthien Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src-tauri/src/automation/roster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -63,6 +63,17 @@ pub struct WatchableTerminal {
pub shell: Option<String>,
pub pid: Option<u32>,
pub cwd: Option<String>,
/// 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<String>,
/// 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<String>,
/// `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,
Expand Down Expand Up @@ -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,
});
}
Expand All @@ -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,
});
}
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/automation/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ impl AutomationRuntime {
self.watched.get(rule_id).map(|e| e.value().clone()).unwrap_or_default()
}

/// Has the targeting loop resolved this rule's matched set even once?
///
/// `watched_for` cannot answer this: it flattens a MISSING entry and an entry holding an empty
/// set into the same empty set, and those two mean opposite things to the row that reads them.
/// The targeting pass calls `set_watched` for every live rule, empty result included, so an
/// absent key means "not resolved yet" and only that.
pub fn has_resolved(&self, rule_id: &str) -> bool {
self.watched.contains_key(rule_id)
}

pub fn watches(&self, rule_id: &str, tm: &str) -> bool {
self.watched.get(rule_id).map(|e| e.value().contains(tm)).unwrap_or(false)
}
Expand Down
126 changes: 116 additions & 10 deletions src-tauri/src/automation/targeting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub excluded: BTreeSet<String>,
pub watching: BTreeSet<String>,
}

/// 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<String>>,
) -> 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<String> = 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 {
Expand Down Expand Up @@ -165,13 +209,7 @@ pub fn watched_set(
rows: &[RosterRow],
previous: Option<&BTreeSet<String>>,
) -> BTreeSet<String> {
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),
},
}
resolve_target_sets(rule, rows, previous).watching
}

#[cfg(test)]
Expand Down Expand Up @@ -466,28 +504,32 @@ 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,
schema_version: 1,
graph: AutomationGraph {
layout: None,
timer: None,
monitor: Some(MonitorStep { read: ReadMode::NewOutput, cadence: Cadence::OnOutput, every_ms: 0 }),
monitor: Some(MonitorStep { read: ReadMode::NewOutput, cadence: Cadence::OnOutput, every_ms: 0, skip_typed_line: false }),
parse: Some(ParseStep {
preset: ParsePreset::Custom,
literal: None,
find: r"ctx:(\d+)%".into(),
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,
Expand Down Expand Up @@ -565,6 +607,70 @@ 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"]);
}

/// 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
// -----------------------------------------------------------------------------------------
Expand Down
Loading
Loading