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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,11 +531,12 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg

**4 trigger types:** `message_posted`, `reaction_added`, `schedule`, `webhook`

**7 action types:**
**8 action types:**

| Action | Description |
|--------|-------------|
| `send_message` | Post to the workflow's channel (or override channel) |
| `assign_agent` | Dispatch a task to exactly one agent by hex pubkey (or a single template resolving to one); fails closed if the assignee is not a channel member |
| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) |
| `set_channel_topic` | Update channel topic |
| `add_reaction` | React to the trigger message |
Expand Down
588 changes: 583 additions & 5 deletions crates/buzz-relay/src/workflow_sink.rs

Large diffs are not rendered by default.

60 changes: 59 additions & 1 deletion crates/buzz-workflow/src/action_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,27 @@ pub enum ActionSinkError {
/// Message content is empty or whitespace-only.
#[error("empty message content")]
EmptyContent,
/// The target agent is not a member of the destination channel.
///
/// `assign_agent` is fail-closed: the agent must already be a channel
/// member. Silently adding them would let a workflow escalate authority
/// beyond what the owner granted at save time.
#[error("assignee is not a channel member: {0}")]
AssigneeNotMember(String),
}

impl From<ActionSinkError> for crate::WorkflowError {
fn from(e: ActionSinkError) -> Self {
crate::WorkflowError::WebhookError(e.to_string())
match e {
// Keep a database failure classified as one. An operator triaging
// a failed run has to be able to tell "the database was down"
// from "the assignee was removed from the channel"; both landing
// on `webhook_failed` made that impossible.
ActionSinkError::Database(msg) => crate::WorkflowError::Database(msg),
// Everything else is a genuine action failure. `webhook_failed`
// was always a misnomer here — none of these actions is a webhook.
other => crate::WorkflowError::ActionFailed(other.to_string()),
}
}
}

Expand Down Expand Up @@ -66,4 +82,46 @@ pub trait ActionSink: Send + Sync {
text: &str,
author_pubkey: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>>;

/// Dispatch a task to exactly one agent by immutable pubkey.
///
/// The relay-side implementation emits two `p` tags on the resulting
/// `kind:9` message — `author_pubkey` (owner attribution) and
/// `agent_pubkey` (wake) — collapsing to one when the owner *is* the
/// assignee. It also emits `buzz:workflow-owner`, which is what the
/// harness's inbound author gate actually reads; the event is signed by
/// the relay keypair, so without that tag it is gated on the relay's own
/// pubkey and dropped under `owner-only`. The `text` is **not** scanned
/// for `@Name` mentions — that reverse-parse is the failure mode
/// `assign_agent` exists to avoid.
///
/// Fails with [`ActionSinkError::AssigneeNotMember`] if `agent_pubkey`
/// is not a current member of `channel_id`. Adding them silently would
/// let a workflow escalate beyond the owner's saved authority.
///
/// - `agent_pubkey`: hex-encoded pubkey of the sole assignee.
/// - `task_id`: optional caller-supplied correlation id emitted as a
/// `task` tag, trimmed, and omitted entirely when empty after trimming.
///
/// It is **not** guaranteed to be a UUID at this boundary. Definition
/// validation requires one, but the executor re-validates only
/// `agent_pubkey` before calling, and this is a public trait any caller
/// can implement against — so an implementation must not assume the
/// shape. (The previous wording claimed "the executor performs shape
/// validation before calling", which was not true of `task_id`.)
///
/// Returns the event ID hex string on success.
///
/// No default implementation is provided intentionally: there is only
/// one production sink, and a runtime "unimplemented" would defeat the
/// identity-safety guarantees this method is being added to enforce.
fn assign_agent(
&self,
community_id: CommunityId,
channel_id: &str,
text: &str,
author_pubkey: &str,
agent_pubkey: &str,
task_id: Option<&str>,
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>>;
}
10 changes: 10 additions & 0 deletions crates/buzz-workflow/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ pub enum WorkflowError {
#[error("webhook error: {0}")]
WebhookError(String),

/// A side-effect action (`send_message`, `assign_agent`) failed.
///
/// Distinct from [`Self::WebhookError`]: these actions are not webhooks,
/// and collapsing them into `webhook_failed` left an operator unable to
/// tell a removed assignee from an archived channel from a genuine
/// outbound HTTP failure.
#[error("action failed: {0}")]
ActionFailed(String),

/// The engine's concurrency limit was reached.
#[error("capacity exceeded")]
CapacityExceeded,
Expand Down Expand Up @@ -75,6 +84,7 @@ impl WorkflowError {
Self::TemplateError(_) => "template_resolution_failed",
Self::StepTimeout { .. } => "step_timeout",
Self::WebhookError(_) => "webhook_failed",
Self::ActionFailed(_) => "action_failed",
Self::CapacityExceeded => "capacity_exceeded",
Self::Database(_) => "database_error",
Self::Unauthorized(_) => "owner_unauthorized",
Expand Down
Loading