Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Slack/Discord webhook sink** for state-transition push
notifications. New `[sinks.webhook]` config table with
`enabled` / `endpoint` / `endpoint_env` / `flavor` / `on_states`
/ `rate_limit_secs` keys. Defaults forward only `WaitingInput`
and `Error` transitions — most agent transitions are routine
`Idle ↔ Working` and would spam. Auto-detects Slack
(`hooks.slack.com` → `{"text": "..."}`) and Discord
(`discord.com/api/webhooks` → `{"content": "..."}`) from the
URL; falls back to a generic flavor that posts the full
`Transition` JSON. Per-`(kind, session_id, state)` in-task
rate-limit (default 60s) prevents flapping permission loops
from paging the operator 30 times a minute. Best-effort by
design — failed POSTs log at WARN and drop, no on-disk queue,
no retry backoff. The webhook URL is the secret on Slack and
Discord, so prefer `endpoint_env` over inline TOML.
- **`muxa logs`** — tail muxad's stdout/stderr without remembering
`/tmp/muxad.log` and `/tmp/muxad.err`. Default streams the last 30
lines of both files (configurable via `-n/--lines`) then follows
Expand Down
23 changes: 23 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,26 @@
# device_id = "laptop-01"
# batch_size = 50
# flush_interval_ms = 5000

# [sinks.webhook] forwards `Transition` events to a Slack or Discord
# webhook so you can be paged on your phone the moment an agent flips
# to `WaitingInput` or `Error` while you're AFK from `muxa watch`.
# Auto-detects the wire format from the URL: hooks.slack.com →
# `{"text": "..."}`, discord.com/api/webhooks → `{"content": "..."}`,
# anything else → the full `Transition` JSON.
#
# The webhook URL is itself the secret for Slack/Discord; prefer
# `endpoint_env` over inline `endpoint` so it never lives in TOML.
# `endpoint_env` wins when both are set.
#
# Best-effort by design: a failed POST logs and drops. No queue, no
# backoff — Slack/Discord's own rate-limit handling is good enough,
# and we'd rather lose a single page than thunder-herd 1000 buffered
# alerts when an outage clears.
[sinks.webhook]
# enabled = true
# endpoint = "https://hooks.slack.com/services/T0/B0/abc"
# endpoint_env = "MUXA_SLACK_URL"
# flavor = "slack" # slack | discord | generic
# on_states = ["WaitingInput", "Error"]
# rate_limit_secs = 60 # per (kind, session, state)
141 changes: 141 additions & 0 deletions crates/muxa/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ pub enum ConfigError {
not set (no default endpoint by design)"
)]
OhMyPromptMissingEndpoint,

#[error(
"sinks.webhook: enabled = true but neither [sinks.webhook].endpoint \
nor [sinks.webhook].endpoint_env is set (one of the two is required)"
)]
WebhookMissingEndpoint,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -102,6 +108,7 @@ pub struct Config {
#[serde(default, deny_unknown_fields)]
pub struct SinksConfig {
pub oh_my_prompt: OhMyPromptToml,
pub webhook: WebhookToml,
}

/// `[sinks.oh_my_prompt]` raw TOML schema. The daemon resolves these
Expand All @@ -126,6 +133,36 @@ pub struct OhMyPromptToml {
pub flush_interval_ms: Option<u64>,
}

/// `[sinks.webhook]` raw TOML schema. The daemon resolves these fields
/// against env vars + defaults via `WebhookSink::resolve` at startup.
///
/// Either `endpoint` (URL inline in TOML) OR `endpoint_env` (name of an
/// env var holding the URL) is required when `enabled = true`. The
/// env-var path is preferred for Slack/Discord webhooks because the URL
/// itself is the secret — committing it to a shared dotfile is a leak.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WebhookToml {
pub enabled: Option<bool>,
/// Full webhook URL. Mutually-optional with `endpoint_env`; the env
/// var wins when both are set.
pub endpoint: Option<String>,
/// Name of an env var holding the full webhook URL. Set this in
/// preference to `endpoint` so the secret URL never lives in TOML.
pub endpoint_env: Option<String>,
/// Wire-format flavor: `slack` | `discord` | `generic`. Auto-detected
/// from the URL when unset.
pub flavor: Option<String>,
/// State transitions to forward. Defaults to `["WaitingInput",
/// "Error"]` — the two states that mean "operator attention needed".
/// `PascalCase` or `snake_case` are both accepted at resolve time.
pub on_states: Option<Vec<String>>,
/// Per-`(kind, session_id, state)` rate-limit window in seconds.
/// Defaults to 60. Set to 0 to disable (one notification per
/// transition, even if the agent flaps).
pub rate_limit_secs: Option<u64>,
}

/// `[dashboard]` config — the user-facing TOML schema for the dashboard
/// HTTP server. All fields are `Option` so the config-file layer can
/// distinguish "not set" (use default or env/flag override) from
Expand Down Expand Up @@ -429,6 +466,7 @@ impl Config {
pub fn validate_for_daemon(&self) -> std::result::Result<(), ConfigError> {
validate_dashboard(&self.dashboard)?;
validate_oh_my_prompt(&self.sinks.oh_my_prompt)?;
validate_webhook(&self.sinks.webhook)?;
Ok(())
}

Expand Down Expand Up @@ -514,6 +552,18 @@ fn validate_oh_my_prompt(cfg: &OhMyPromptToml) -> std::result::Result<(), Config
Ok(())
}

fn validate_webhook(cfg: &WebhookToml) -> std::result::Result<(), ConfigError> {
if !cfg.enabled.unwrap_or(false) {
return Ok(());
}
let has_endpoint = cfg.endpoint.as_deref().is_some_and(|s| !s.is_empty());
let has_endpoint_env = cfg.endpoint_env.as_deref().is_some_and(|s| !s.is_empty());
if !has_endpoint && !has_endpoint_env {
return Err(ConfigError::WebhookMissingEndpoint);
}
Ok(())
}

/// Walk a `[watch.detail] template` string and yield each placeholder name
/// (or pipe-fallback name) that isn't in [`WATCH_DETAIL_PLACEHOLDERS`].
/// Unbalanced `{` / missing `}` are tolerated silently — the runtime
Expand Down Expand Up @@ -1201,6 +1251,7 @@ default_content = "nope"
enabled: Some(true),
..OhMyPromptToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
Expand All @@ -1224,6 +1275,7 @@ default_content = "nope"
endpoint: Some("https://example.dev".into()),
..OhMyPromptToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
Expand All @@ -1238,6 +1290,94 @@ default_content = "nope"
assert!(cfg.validate_for_daemon().is_ok());
}

/// `[sinks.webhook] enabled = true` with neither endpoint nor
/// `endpoint_env` set must fail at load — there is no default URL,
/// and a sink that can't deliver alerts is worse than no sink at
/// all (operator thinks they're being watched).
#[test]
fn validate_rejects_webhook_enabled_without_endpoint() {
let cfg = Config {
sinks: SinksConfig {
webhook: WebhookToml {
enabled: Some(true),
..WebhookToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
let err = cfg.validate_for_daemon().unwrap_err();
assert!(
matches!(err, ConfigError::WebhookMissingEndpoint),
"got {err:?}",
);
}

/// Either `endpoint` or `endpoint_env` is sufficient. We don't
/// validate the env var contents here because that's a daemon-runtime
/// concern — the env var may be populated only inside the unit
/// `Environment=` and the user's interactive shell wouldn't see it.
#[test]
fn validate_accepts_webhook_with_endpoint() {
let cfg = Config {
sinks: SinksConfig {
webhook: WebhookToml {
enabled: Some(true),
endpoint: Some("https://hooks.slack.com/services/T0/B0/x".into()),
..WebhookToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
assert!(cfg.validate_for_daemon().is_ok());
}

#[test]
fn validate_accepts_webhook_with_endpoint_env_only() {
let cfg = Config {
sinks: SinksConfig {
webhook: WebhookToml {
enabled: Some(true),
endpoint_env: Some("MUXA_SLACK_URL".into()),
..WebhookToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
assert!(cfg.validate_for_daemon().is_ok());
}

/// Webhook section parses with the documented field set and
/// `deny_unknown_fields` rejects typos.
#[test]
fn parses_webhook_section() {
let toml = r#"
[sinks.webhook]
enabled = true
endpoint = "https://hooks.slack.com/services/T0/B0/x"
flavor = "slack"
on_states = ["WaitingInput", "Error"]
rate_limit_secs = 30
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.sinks.webhook.enabled, Some(true));
assert_eq!(cfg.sinks.webhook.flavor.as_deref(), Some("slack"));
assert_eq!(cfg.sinks.webhook.rate_limit_secs, Some(30));
}

#[test]
fn rejects_unknown_field_in_webhook_section() {
let toml = r#"
[sinks.webhook]
enabled = true
endpoint = "https://example.com"
typoed_field = 1
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}

/// CLI commands like `muxa watch` only call `Config::validate()`,
/// never `validate_for_daemon()`. A config that's *only*
/// daemon-misconfigured (e.g. `dashboard.bind = "0.0.0.0:7878"` with
Expand All @@ -1259,6 +1399,7 @@ default_content = "nope"
enabled: Some(true),
..OhMyPromptToml::default()
},
..SinksConfig::default()
},
..Config::default()
};
Expand Down
2 changes: 1 addition & 1 deletion crates/muxa/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub enum AgentKind {
Unknown,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, strum::Display)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AgentState {
Expand Down
5 changes: 5 additions & 0 deletions crates/muxa/src/sinks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@
//!
//! - [`ohmyprompt`] — HTTP POST to `oh-my-prompt`'s `/api/sync/upload`
//! endpoint with batching, retries, and a bounded ring-buffer.
//! - [`webhook`] — single-shot HTTP POST to a Slack/Discord webhook
//! on state transitions (default: `WaitingInput`/`Error`). Best-effort,
//! no queue, in-task per-agent rate-limit.
//!
//! Each sink owns its own runtime contract (wire schema, auth header
//! shape, retry policy) — there is intentionally no `Sink` trait in v1
//! because the only consumer (`muxad`) calls each impl by name and the
//! shapes diverge enough that a uniform interface would be premature.

pub mod ohmyprompt;
pub mod webhook;

pub use ohmyprompt::{OhMyPromptError, OhMyPromptSink};
pub use webhook::{WebhookError, WebhookSink};
Loading