Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 7 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,10 @@ steps:

Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Triggers use `on:` as the tag field; actions use `action:` as the tag field. Fields are flattened into the parent struct, not nested.

**4 trigger types:** `message_posted`, `reaction_added`, `schedule`, `webhook`
**6 trigger types:** `message_posted`, `slash_command`, `reaction_added`,
`diff_posted`, `schedule`, `webhook`. A `slash_command` trigger owns an exact
bare command such as `/new-task`; mention-prefixed commands remain ACP runtime
commands. The argument tail is available as `{{trigger.args}}`.

**7 action types:**

Expand All @@ -543,7 +546,9 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
| `delay` | Pause execution (max 300 seconds) |

**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.
**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`,
`{{trigger.command}}`, `{{trigger.args}}`, `{{steps.ID.output.FIELD}}`.
Single-pass resolution (not recursive). Unknown variables left as literal text.

**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Agents are part of the room, not haunted cron jobs.
| Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays |
| Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications |
| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features |
| YAML workflows: message / reaction / schedule / webhook triggers | | |
| YAML workflows: message / slash-command / reaction / diff / schedule / webhook triggers | | |
| Git events (NIP-34: patches, repo announcements, status) | | |
| Git hosting backend | | |

Expand Down
41 changes: 41 additions & 0 deletions crates/buzz-workflow/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ pub struct TriggerContext {
/// NIP-10 `reply`/`root` marker e-tag). Lets a `message_posted` filter
/// select only top-level messages via `trigger_is_reply == false`.
pub is_reply: bool,
/// Matched slash command name without the leading slash. Empty for other
/// trigger types.
#[serde(default)]
pub command: String,
/// Text following the matched slash command, trimmed at both ends. Empty
/// when the command has no arguments or for other trigger types.
#[serde(default)]
pub args: String,
/// Arbitrary webhook body fields (webhook trigger).
pub webhook_fields: HashMap<String, String>,
}
Expand All @@ -58,6 +66,8 @@ impl TriggerContext {
"timestamp" => Some(&self.timestamp),
"emoji" => Some(&self.emoji),
"message_id" => Some(&self.message_id),
"command" => Some(&self.command),
"args" => Some(&self.args),
other => self.webhook_fields.get(other).map(|s| s.as_str()),
}
}
Expand Down Expand Up @@ -217,6 +227,8 @@ fn apply_filter(value: String, filter: &str) -> Result<String, WorkflowError> {
/// | `trigger.timestamp` | `trigger_timestamp` |
/// | `trigger.emoji` | `trigger_emoji` |
/// | `trigger.message_id` | `trigger_message_id` |
/// | `trigger.command` | `trigger_command` |
/// | `trigger.args` | `trigger_args` |
/// | `trigger.is_reply` | `trigger_is_reply` (bool) |
/// | `steps.STEP_ID.output.FIELD` | `steps_STEP_ID_output_FIELD` |
///
Expand Down Expand Up @@ -298,6 +310,8 @@ pub fn build_eval_context(
("trigger_timestamp", trigger_ctx.timestamp.as_str()),
("trigger_emoji", trigger_ctx.emoji.as_str()),
("trigger_message_id", trigger_ctx.message_id.as_str()),
("trigger_command", trigger_ctx.command.as_str()),
("trigger_args", trigger_ctx.args.as_str()),
];

for (name, val) in &trigger_fields {
Expand Down Expand Up @@ -1311,6 +1325,8 @@ mod tests {
emoji: "fire".to_owned(),
message_id: "event-id-hex".to_owned(),
is_reply: false,
command: "new-task".to_owned(),
args: "Build feature XYZ".to_owned(),
webhook_fields: HashMap::new(),
}
}
Expand All @@ -1329,6 +1345,18 @@ mod tests {
assert_eq!(out, "By abc123def456");
}

#[test]
fn resolve_slash_command_fields() {
let ctx = make_trigger();
let out = resolve_template(
"/{{trigger.command}} dispatched: {{trigger.args}}",
&ctx,
&HashMap::new(),
)
.unwrap();
assert_eq!(out, "/new-task dispatched: Build feature XYZ");
}

#[test]
fn resolve_step_output() {
let ctx = make_trigger();
Expand Down Expand Up @@ -1419,6 +1447,19 @@ mod tests {
assert!(result);
}

#[tokio::test]
async fn condition_can_filter_slash_command_and_args() {
let ctx = make_trigger();
let result = evaluate_condition(
"trigger_command == \"new-task\" && str_contains(trigger_args, \"feature XYZ\")",
&ctx,
&HashMap::new(),
)
.await
.unwrap();
assert!(result);
}

#[tokio::test]
async fn condition_false_when_text_does_not_contain_p1() {
let mut ctx = make_trigger();
Expand Down
129 changes: 118 additions & 11 deletions crates/buzz-workflow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,14 +359,6 @@ impl WorkflowEngine {

let trigger_ctx = build_trigger_context(event);

let trigger_ctx_json: serde_json::Value = match serde_json::to_value(&trigger_ctx) {
Ok(v) => v,
Err(e) => {
tracing::error!("Failed to serialize trigger context: {e}");
return Ok(());
}
};

for workflow in workflows.iter() {
let def: WorkflowDef = match serde_json::from_value(workflow.definition.clone()) {
Ok(d) => d,
Expand All @@ -380,10 +372,25 @@ impl WorkflowEngine {
continue;
}

if !should_fire_workflow(&def, &trigger_ctx, workflow.id).await {
let Some(workflow_trigger_ctx) =
trigger_context_for_workflow(&def.trigger, &trigger_ctx)
else {
continue;
};

if !should_fire_workflow(&def, &workflow_trigger_ctx, workflow.id).await {
continue;
}

let trigger_ctx_json: serde_json::Value =
match serde_json::to_value(&workflow_trigger_ctx) {
Ok(v) => v,
Err(e) => {
tracing::error!("Failed to serialize trigger context: {e}");
continue;
}
};

// SEC-006: recheck the owner's *current* channel authority
// immediately before run creation. The cached workflow list can be
// up to 10s stale, and disable-on-removal can race a concurrent
Expand Down Expand Up @@ -427,7 +434,7 @@ impl WorkflowEngine {

let engine = Arc::clone(self);
let def_clone = def.clone();
let ctx_clone = trigger_ctx.clone();
let ctx_clone = workflow_trigger_ctx;

tokio::spawn(async move {
let result =
Expand Down Expand Up @@ -904,7 +911,8 @@ async fn should_fire_workflow(
let filter = match &def.trigger {
TriggerDef::MessagePosted { filter }
| TriggerDef::ReactionAdded { filter, .. }
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
| TriggerDef::DiffPosted { filter }
| TriggerDef::SlashCommand { filter, .. } => filter.as_ref(),
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
};
if let Some(expr) = filter {
Expand All @@ -930,6 +938,42 @@ async fn should_fire_workflow(
true
}

/// Prepare the per-workflow trigger context after kind-level matching.
///
/// Ordinary event triggers reuse the base context unchanged. Slash commands
/// additionally require an exact bare command token and expose the parsed name
/// and argument tail as `trigger.command` / `trigger.args`. A leading mention
/// never matches, preserving `@Agent /command` for ACP pass-through.
fn trigger_context_for_workflow(
trigger: &TriggerDef,
base: &executor::TriggerContext,
) -> Option<executor::TriggerContext> {
let TriggerDef::SlashCommand { command, .. } = trigger else {
return Some(base.clone());
};

let args = match_slash_command(&base.text, command)?;
let mut context = base.clone();
context.command.clone_from(command);
context.args = args;
Some(context)
}

/// Match `/name` or `/name <args>` at the start of a message.
///
/// Leading whitespace is ignored, matching ACP command handling. The command
/// token must end at whitespace or end-of-input, so `/new-task-extra` cannot
/// accidentally invoke `new-task`.
fn match_slash_command(content: &str, expected: &str) -> Option<String> {
let after_slash = content.trim_start().strip_prefix('/')?;
let remainder = after_slash.strip_prefix(expected)?;
match remainder.chars().next() {
None => Some(String::new()),
Some(c) if c.is_whitespace() => Some(remainder.trim().to_owned()),
Some(_) => None,
}
}

/// Build a [`executor::TriggerContext`] from a [`buzz_core::StoredEvent`].
///
/// - `text` — event content (message body or reaction emoji character)
Expand Down Expand Up @@ -998,6 +1042,8 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge
emoji,
message_id,
is_reply: event_is_reply(&event.event),
command: String::new(),
args: String::new(),
webhook_fields: HashMap::new(),
}
}
Expand Down Expand Up @@ -1041,6 +1087,7 @@ fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE,
TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION,
TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF,
TriggerDef::SlashCommand { .. } => kind_u32 == KIND_STREAM_MESSAGE,
// Schedule and Webhook triggers are not fired by channel events.
TriggerDef::Schedule { .. } | TriggerDef::Webhook => false,
}
Expand Down Expand Up @@ -1357,6 +1404,66 @@ steps:
));
}

#[test]
fn slash_command_matches_stream_message_kind_only() {
let trigger = TriggerDef::SlashCommand {
command: "new-task".to_owned(),
filter: None,
};
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
}

#[test]
fn slash_command_requires_exact_bare_token_and_extracts_args() {
assert_eq!(
match_slash_command("/new-task Build feature XYZ", "new-task"),
Some("Build feature XYZ".to_owned())
);
assert_eq!(
match_slash_command(" /new-task\nBuild feature XYZ ", "new-task"),
Some("Build feature XYZ".to_owned())
);
assert_eq!(
match_slash_command("/new-task", "new-task"),
Some(String::new())
);
assert_eq!(
match_slash_command("/new-task-extra nope", "new-task"),
None
);
assert_eq!(
match_slash_command("@Hermes /new-task nope", "new-task"),
None,
"mention-prefixed commands belong to ACP pass-through"
);
assert_eq!(match_slash_command("see /new-task", "new-task"), None);
}

#[test]
fn slash_command_populates_per_workflow_trigger_context() {
let trigger = TriggerDef::SlashCommand {
command: "new-task".to_owned(),
filter: None,
};
let base = executor::TriggerContext {
text: "/new-task Build feature XYZ".to_owned(),
author: "abc123".to_owned(),
..Default::default()
};

let context = trigger_context_for_workflow(&trigger, &base).expect("should match");
assert_eq!(context.command, "new-task");
assert_eq!(context.args, "Build feature XYZ");
assert_eq!(context.author, "abc123");
}

#[test]
fn trigger_matches_reaction() {
let trigger = TriggerDef::ReactionAdded {
Expand Down
Loading