huskies: merge 1038

This commit is contained in:
dave
2026-05-14 17:00:33 +00:00
parent 4553df5b21
commit 1f9f34ab58
13 changed files with 940 additions and 2 deletions
+174
View File
@@ -0,0 +1,174 @@
//! Event-based pipeline triggers: register conditions on [`TransitionFired`] events
//! and automatically execute MCP tool calls or agent prompts when they fire.
/// Persistent storage for event triggers backed by a JSON file.
pub mod store;
use crate::pipeline_state::{TransitionFired, event_label, stage_label};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Predicate for matching a [`TransitionFired`] event.
///
/// Every `Some` field must match for the predicate to pass; `None` fields
/// are wildcards. All comparisons are case-insensitive.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerPredicate {
/// Match only transitions for this story (e.g. `"42_my_feature"`).
pub story_id: Option<String>,
/// Match only when `fired.before` has this label (e.g. `"Merge"`, `"Coding"`).
pub from_stage: Option<String>,
/// Match only when `fired.after` has this label (e.g. `"Done"`, `"MergeFailure"`).
pub to_stage: Option<String>,
/// Match only when the event has this label (e.g. `"MergeFailed"`, `"Block"`).
pub event_kind: Option<String>,
}
impl TriggerPredicate {
/// Returns `true` if every non-`None` field matches `fired`.
pub fn matches(&self, fired: &TransitionFired) -> bool {
if let Some(sid) = &self.story_id
&& !fired.story_id.0.eq_ignore_ascii_case(sid)
{
return false;
}
if let Some(from) = &self.from_stage
&& !stage_label(&fired.before).eq_ignore_ascii_case(from)
{
return false;
}
if let Some(to) = &self.to_stage
&& !stage_label(&fired.after).eq_ignore_ascii_case(to)
{
return false;
}
if let Some(kind) = &self.event_kind
&& !event_label(&fired.event).eq_ignore_ascii_case(kind)
{
return false;
}
true
}
}
/// Action to execute when a trigger fires.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerAction {
/// Call an MCP tool deterministically (no LLM in the loop).
Mcp {
method: String,
args: serde_json::Value,
},
/// Spawn a short-lived focused agent with this text as its task prompt.
Prompt { text: String },
}
/// Whether a trigger fires once then is removed, or fires repeatedly until cancelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FireMode {
/// Remove the trigger after its first match.
Once,
/// Keep the trigger active until explicitly cancelled.
Persistent,
}
/// A registered event trigger.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventTrigger {
/// Unique identifier (UUIDv4).
pub id: String,
/// Predicate applied to every [`TransitionFired`].
pub predicate: TriggerPredicate,
/// Action to execute on match.
pub action: TriggerAction,
/// Whether this trigger fires once or persists.
pub mode: FireMode,
/// When the trigger was registered.
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_state::{PipelineEvent, Stage, StoryId, TransitionFired};
fn fired(story: &str, before: Stage, after: Stage, event: PipelineEvent) -> TransitionFired {
TransitionFired {
story_id: StoryId(story.to_string()),
before,
after,
event,
at: Utc::now(),
}
}
#[test]
fn predicate_wildcard_matches_anything() {
let p = TriggerPredicate {
story_id: None,
from_stage: None,
to_stage: None,
event_kind: None,
};
let f = fired("42_foo", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
assert!(p.matches(&f));
}
#[test]
fn predicate_story_id_filter() {
let p = TriggerPredicate {
story_id: Some("42_foo".to_string()),
from_stage: None,
to_stage: None,
event_kind: None,
};
let yes = fired("42_foo", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
let no = fired("99_bar", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
assert!(p.matches(&yes));
assert!(!p.matches(&no));
}
#[test]
fn predicate_to_stage_filter() {
let p = TriggerPredicate {
story_id: None,
from_stage: None,
to_stage: Some("Done".to_string()),
event_kind: None,
};
let yes = fired(
"1",
Stage::Merge {
feature_branch: crate::pipeline_state::BranchName("b".into()),
commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
claim: None,
retries: 0,
server_start_time: None,
},
Stage::Done {
merged_at: Utc::now(),
merge_commit: crate::pipeline_state::GitSha("abc".into()),
},
PipelineEvent::MergeSucceeded {
merge_commit: crate::pipeline_state::GitSha("abc".into()),
},
);
let no = fired("1", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
assert!(p.matches(&yes));
assert!(!p.matches(&no));
}
#[test]
fn predicate_event_kind_case_insensitive() {
let p = TriggerPredicate {
story_id: None,
from_stage: None,
to_stage: None,
event_kind: Some("depsmet".to_string()),
};
let f = fired("1", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
assert!(p.matches(&f));
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Persistent store for registered event triggers, backed by a JSON file.
//!
//! Loaded at server startup and kept in sync on every mutation. Thread-safe
//! via an internal `Mutex`.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use chrono::Utc;
use serde_json::Value;
use super::{EventTrigger, FireMode, TriggerAction, TriggerPredicate};
/// Persistent store for [`EventTrigger`] entries.
pub struct EventTriggerStore {
path: PathBuf,
triggers: Mutex<Vec<EventTrigger>>,
}
impl EventTriggerStore {
/// Load the store from `path`. Returns an empty store if the file does
/// not exist or cannot be parsed.
pub fn load(path: PathBuf) -> Self {
let triggers = if path.exists() {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Vec<EventTrigger>>(&s).ok())
.unwrap_or_default()
} else {
Vec::new()
};
Self {
path,
triggers: Mutex::new(triggers),
}
}
fn persist(path: &Path, triggers: &[EventTrigger]) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {e}"))?;
}
let json = serde_json::to_string_pretty(triggers)
.map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(path, json).map_err(|e| format!("Failed to write triggers: {e}"))
}
/// Register a new trigger and persist to disk.
pub fn add(
&self,
predicate: TriggerPredicate,
action: TriggerAction,
mode: FireMode,
) -> Result<EventTrigger, String> {
let trigger = EventTrigger {
id: uuid_v4(),
predicate,
action,
mode,
created_at: Utc::now(),
};
let mut triggers = self.triggers.lock().unwrap();
triggers.push(trigger.clone());
Self::persist(&self.path, &triggers)?;
Ok(trigger)
}
/// Return a snapshot of all registered triggers.
pub fn list(&self) -> Vec<EventTrigger> {
self.triggers.lock().unwrap().clone()
}
/// Remove the trigger with `id`. Returns `true` if it was found and removed.
pub fn cancel(&self, id: &str) -> bool {
let mut triggers = self.triggers.lock().unwrap();
let before = triggers.len();
triggers.retain(|t| t.id != id);
let removed = triggers.len() < before;
if removed {
let _ = Self::persist(&self.path, &triggers);
}
removed
}
/// Remove all triggers whose ids are in `ids` and return how many were removed.
///
/// Used by the subscriber to delete `Once` triggers after they fire.
pub fn cancel_batch(&self, ids: &[String]) -> usize {
if ids.is_empty() {
return 0;
}
let mut triggers = self.triggers.lock().unwrap();
let before = triggers.len();
triggers.retain(|t| !ids.contains(&t.id));
let removed = before - triggers.len();
if removed > 0 {
let _ = Self::persist(&self.path, &triggers);
}
removed
}
}
/// Generate a random UUIDv4-style identifier without pulling in the full uuid crate.
///
/// Uses [`std::time`] entropy mixed with a thread-local counter. Not cryptographically
/// strong, but unique enough for trigger IDs.
fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let hi = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{hi:016x}-{nanos:08x}-4000-0000-{hi:012x}")
}
/// Parse a [`TriggerAction`] from the raw JSON supplied in an MCP args object.
pub fn parse_action(args: &Value) -> Result<TriggerAction, String> {
let action_obj = args
.get("action")
.ok_or("Missing required argument: action")?;
let action_type = action_obj
.get("type")
.and_then(|v| v.as_str())
.ok_or("action.type must be a string (\"mcp\" or \"prompt\")")?;
match action_type {
"mcp" => {
let method = action_obj
.get("method")
.and_then(|v| v.as_str())
.ok_or("action.method is required for type=mcp")?
.to_string();
let action_args = action_obj
.get("args")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
Ok(TriggerAction::Mcp {
method,
args: action_args,
})
}
"prompt" => {
let text = action_obj
.get("text")
.and_then(|v| v.as_str())
.ok_or("action.text is required for type=prompt")?
.to_string();
Ok(TriggerAction::Prompt { text })
}
other => Err(format!(
"Unknown action type '{other}'; expected \"mcp\" or \"prompt\""
)),
}
}
/// Parse a [`TriggerPredicate`] from the raw JSON supplied in an MCP args object.
pub fn parse_predicate(args: &Value) -> Result<TriggerPredicate, String> {
let pred = args
.get("predicate")
.ok_or("Missing required argument: predicate")?;
Ok(TriggerPredicate {
story_id: pred
.get("story_id")
.and_then(|v| v.as_str())
.map(str::to_string),
from_stage: pred
.get("from_stage")
.and_then(|v| v.as_str())
.map(str::to_string),
to_stage: pred
.get("to_stage")
.and_then(|v| v.as_str())
.map(str::to_string),
event_kind: pred
.get("event_kind")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
/// Parse a [`FireMode`] from the raw JSON supplied in an MCP args object.
pub fn parse_mode(args: &Value) -> FireMode {
match args.get("mode").and_then(|v| v.as_str()) {
Some("persistent") => FireMode::Persistent,
_ => FireMode::Once,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp_store() -> (TempDir, EventTriggerStore) {
let dir = TempDir::new().unwrap();
let store = EventTriggerStore::load(dir.path().join("triggers.json"));
(dir, store)
}
fn basic_pred() -> TriggerPredicate {
TriggerPredicate {
story_id: None,
from_stage: None,
to_stage: Some("Done".to_string()),
event_kind: None,
}
}
fn basic_action() -> TriggerAction {
TriggerAction::Mcp {
method: "get_pipeline_status".to_string(),
args: serde_json::json!({}),
}
}
#[test]
fn store_empty_on_missing_file() {
let (_dir, store) = tmp_store();
assert!(store.list().is_empty());
}
#[test]
fn store_add_and_list() {
let (_dir, store) = tmp_store();
let t = store
.add(basic_pred(), basic_action(), FireMode::Once)
.unwrap();
let list = store.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, t.id);
assert!(matches!(list[0].mode, FireMode::Once));
}
#[test]
fn store_cancel_removes_entry() {
let (_dir, store) = tmp_store();
let t = store
.add(basic_pred(), basic_action(), FireMode::Persistent)
.unwrap();
assert!(store.cancel(&t.id));
assert!(!store.cancel(&t.id));
assert!(store.list().is_empty());
}
#[test]
fn store_persists_and_reloads() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("triggers.json");
let id = {
let store = EventTriggerStore::load(path.clone());
let t = store
.add(basic_pred(), basic_action(), FireMode::Persistent)
.unwrap();
t.id.clone()
};
let store2 = EventTriggerStore::load(path);
let list = store2.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, id);
}
#[test]
fn store_cancel_batch() {
let (_dir, store) = tmp_store();
let a = store
.add(basic_pred(), basic_action(), FireMode::Once)
.unwrap();
let b = store
.add(basic_pred(), basic_action(), FireMode::Once)
.unwrap();
let removed = store.cancel_batch(&[a.id, b.id]);
assert_eq!(removed, 2);
assert!(store.list().is_empty());
}
#[test]
fn parse_action_mcp() {
let args = serde_json::json!({
"action": { "type": "mcp", "method": "get_version", "args": {} }
});
let action = parse_action(&args).unwrap();
assert!(matches!(action, TriggerAction::Mcp { .. }));
}
#[test]
fn parse_action_prompt() {
let args = serde_json::json!({
"action": { "type": "prompt", "text": "investigate the merge failure" }
});
let action = parse_action(&args).unwrap();
assert!(matches!(action, TriggerAction::Prompt { .. }));
}
#[test]
fn parse_action_unknown_type_errors() {
let args = serde_json::json!({ "action": { "type": "webhook" } });
assert!(parse_action(&args).is_err());
}
#[test]
fn parse_predicate_all_fields() {
let args = serde_json::json!({
"predicate": {
"story_id": "42_foo",
"from_stage": "Coding",
"to_stage": "Done",
"event_kind": "MergeSucceeded"
}
});
let pred = parse_predicate(&args).unwrap();
assert_eq!(pred.story_id.as_deref(), Some("42_foo"));
assert_eq!(pred.from_stage.as_deref(), Some("Coding"));
assert_eq!(pred.to_stage.as_deref(), Some("Done"));
assert_eq!(pred.event_kind.as_deref(), Some("MergeSucceeded"));
}
#[test]
fn parse_mode_defaults_to_once() {
let args = serde_json::json!({});
assert_eq!(parse_mode(&args), FireMode::Once);
}
#[test]
fn parse_mode_persistent() {
let args = serde_json::json!({ "mode": "persistent" });
assert_eq!(parse_mode(&args), FireMode::Persistent);
}
}
+2
View File
@@ -15,6 +15,8 @@ pub mod bot_command;
pub mod common;
/// Diagnostics — server logs, CRDT dump, and permission management.
pub mod diagnostics;
/// Event-based pipeline triggers: register, list, cancel, and execute on TransitionFired events.
pub mod event_triggers;
/// Pipeline event buffer for SSE streaming.
pub mod events;
/// File I/O — path validation, read, write, and listing.