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
+10
View File
@@ -2,6 +2,7 @@
use crate::agents::ReconciliationEvent;
use crate::io::watcher::WatcherEvent;
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
use crate::service::event_triggers::store::EventTriggerStore;
use crate::service::timer::{ScheduledTimerStore, TimerStore};
use crate::services::Services;
use crate::state::SessionState;
@@ -77,6 +78,11 @@ pub struct AppContext {
/// Generic scheduled-timer store for `schedule_timer` / `list_timers` /
/// `cancel_timer` MCP tools. Persists to `.huskies/scheduled_timers.json`.
pub scheduled_timer_store: Arc<ScheduledTimerStore>,
/// Persistent store for event-based pipeline triggers.
///
/// Shared with the background subscriber so that triggers registered via
/// MCP are immediately visible to the subscriber without a disk round-trip.
pub event_trigger_store: Arc<EventTriggerStore>,
}
#[cfg(test)]
@@ -93,6 +99,9 @@ impl AppContext {
let timer_store = Arc::new(TimerStore::load(
project_root.join(".huskies").join("timers.json"),
));
let event_trigger_store = Arc::new(EventTriggerStore::load(
project_root.join(".huskies").join("event_triggers.json"),
));
let scheduled_timer_store = Arc::new(ScheduledTimerStore::load(
project_root.join(".huskies").join("scheduled_timers.json"),
));
@@ -123,6 +132,7 @@ impl AppContext {
matrix_shutdown_tx: None,
timer_store,
scheduled_timer_store,
event_trigger_store,
}
}
}
+5 -1
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
use super::{
agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools,
story_tools, timer_tools, wizard_tools,
story_tools, timer_tools, trigger_tools, wizard_tools,
};
use crate::http::context::AppContext;
@@ -128,6 +128,10 @@ pub async fn dispatch_tool_call(
"wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx),
"wizard_skip" => wizard_tools::tool_wizard_skip(ctx),
"wizard_retry" => wizard_tools::tool_wizard_retry(ctx),
// Event trigger tools
"schedule_event_trigger" => trigger_tools::tool_schedule_event_trigger(&args, ctx),
"list_event_triggers" => trigger_tools::tool_list_event_triggers(ctx),
"cancel_event_trigger" => trigger_tools::tool_cancel_event_trigger(&args, ctx),
// Scheduled timer tools
"schedule_timer" => timer_tools::tool_schedule_timer(&args, ctx),
"list_timers" => timer_tools::tool_list_timers(ctx),
+2
View File
@@ -29,6 +29,8 @@ pub mod story_tools;
pub mod timer_tools;
/// MCP tool schema definitions for `tools/list`.
pub mod tools_list;
/// MCP tools for event-based pipeline triggers.
pub mod trigger_tools;
/// MCP tools for the project setup wizard.
pub mod wizard_tools;
+4 -1
View File
@@ -108,10 +108,13 @@ mod tests {
assert!(names.contains(&"unfreeze_story"));
assert!(names.contains(&"find_orphaned_items"));
assert!(names.contains(&"recover_half_written_items"));
assert!(names.contains(&"schedule_event_trigger"));
assert!(names.contains(&"list_event_triggers"));
assert!(names.contains(&"cancel_event_trigger"));
assert!(names.contains(&"schedule_timer"));
assert!(names.contains(&"list_timers"));
assert!(names.contains(&"cancel_timer"));
assert_eq!(tools.len(), 79);
assert_eq!(tools.len(), 82);
}
#[test]
@@ -349,6 +349,64 @@ pub(super) fn system_tools() -> Vec<Value> {
"properties": {}
}
}),
json!({
"name": "schedule_event_trigger",
"description": "Register an event-based pipeline trigger that fires when a TransitionFired event matches the given predicate. Persists across server restarts. Returns the trigger id.",
"inputSchema": {
"type": "object",
"properties": {
"predicate": {
"type": "object",
"description": "Conditions that must all match for the trigger to fire. Omit a field to match any value (wildcard).",
"properties": {
"story_id": { "type": "string", "description": "Match only transitions for this story id (e.g. '42_my_feature')." },
"from_stage": { "type": "string", "description": "Match only when the stage before the transition equals this label (e.g. 'Merge', 'Coding')." },
"to_stage": { "type": "string", "description": "Match only when the stage after the transition equals this label (e.g. 'Done', 'MergeFailure')." },
"event_kind": { "type": "string", "description": "Match only when the PipelineEvent kind equals this label (e.g. 'MergeFailed', 'Block', 'MergeSucceeded')." }
}
},
"action": {
"type": "object",
"description": "What to do when the trigger fires.",
"properties": {
"type": { "type": "string", "enum": ["mcp", "prompt"], "description": "\"mcp\": call an MCP tool (no LLM). \"prompt\": spawn a focused agent with the text as its task." },
"method": { "type": "string", "description": "For type=mcp: the MCP tool name to call (e.g. 'get_pipeline_status')." },
"args": { "type": "object", "description": "For type=mcp: arguments to pass to the tool." },
"text": { "type": "string", "description": "For type=prompt: the task text for the spawned agent." }
},
"required": ["type"]
},
"mode": {
"type": "string",
"enum": ["once", "persistent"],
"description": "\"once\" (default): remove the trigger after it fires once. \"persistent\": keep it active until cancel_event_trigger is called."
}
},
"required": ["predicate", "action"]
}
}),
json!({
"name": "list_event_triggers",
"description": "Return all currently registered event triggers with their ids, predicates, actions, and fire modes.",
"inputSchema": {
"type": "object",
"properties": {}
}
}),
json!({
"name": "cancel_event_trigger",
"description": "Cancel and remove a registered event trigger by its id.",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The trigger id returned by schedule_event_trigger."
}
},
"required": ["id"]
}
}),
json!({
"name": "find_orphaned_items",
"description": "Find half-written (orphaned) pipeline items: story IDs that exist in the content store but have no live CRDT entry. These are invisible to all normal read paths (list_refactors, get_pipeline_status, etc.) and result from the bug 1001 split-brain race. Returns a list of orphaned IDs with their names and tombstone status. Use recover_half_written_items to fix them.",
+149
View File
@@ -0,0 +1,149 @@
//! MCP tools for event-based pipeline triggers:
//! `schedule_event_trigger`, `list_event_triggers`, `cancel_event_trigger`.
use crate::http::context::AppContext;
use crate::service::event_triggers::store::{parse_action, parse_mode, parse_predicate};
use serde_json::{Value, json};
/// Register a new event trigger that fires when a `TransitionFired` event matches the predicate.
pub(crate) fn tool_schedule_event_trigger(
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
let predicate = parse_predicate(args)?;
let action = parse_action(args)?;
let mode = parse_mode(args);
let trigger = ctx.event_trigger_store.add(predicate, action, mode)?;
serde_json::to_string_pretty(&json!({
"id": trigger.id,
"mode": format!("{:?}", trigger.mode).to_lowercase(),
"created_at": trigger.created_at.to_rfc3339(),
"message": format!("Trigger {} registered.", trigger.id),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// List all currently registered event triggers.
pub(crate) fn tool_list_event_triggers(ctx: &AppContext) -> Result<String, String> {
let triggers = ctx.event_trigger_store.list();
let items: Vec<Value> = triggers
.iter()
.map(|t| {
json!({
"id": t.id,
"mode": format!("{:?}", t.mode).to_lowercase(),
"created_at": t.created_at.to_rfc3339(),
"predicate": {
"story_id": t.predicate.story_id,
"from_stage": t.predicate.from_stage,
"to_stage": t.predicate.to_stage,
"event_kind": t.predicate.event_kind,
},
"action": serde_json::to_value(&t.action).unwrap_or(json!(null)),
})
})
.collect();
serde_json::to_string_pretty(&json!({ "triggers": items, "count": items.len() }))
.map_err(|e| format!("Serialization error: {e}"))
}
/// Cancel (remove) a registered event trigger by its ID.
pub(crate) fn tool_cancel_event_trigger(args: &Value, ctx: &AppContext) -> Result<String, String> {
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: id")?;
if ctx.event_trigger_store.cancel(id) {
Ok(format!("Trigger {id} cancelled."))
} else {
Err(format!("No trigger found with id '{id}'."))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::test_helpers::test_ctx;
#[test]
fn schedule_and_list() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_schedule_event_trigger(
&json!({
"predicate": { "to_stage": "Done" },
"action": { "type": "mcp", "method": "get_pipeline_status", "args": {} },
"mode": "once"
}),
&ctx,
)
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let id = parsed["id"].as_str().unwrap();
assert!(!id.is_empty());
let list_result = tool_list_event_triggers(&ctx).unwrap();
let list: Value = serde_json::from_str(&list_result).unwrap();
assert_eq!(list["count"], 1);
}
#[test]
fn cancel_existing_trigger() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_schedule_event_trigger(
&json!({
"predicate": {},
"action": { "type": "prompt", "text": "investigate" },
"mode": "persistent"
}),
&ctx,
)
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let id = parsed["id"].as_str().unwrap().to_string();
let cancel = tool_cancel_event_trigger(&json!({ "id": id }), &ctx).unwrap();
assert!(cancel.contains("cancelled"));
let list: Value = serde_json::from_str(&tool_list_event_triggers(&ctx).unwrap()).unwrap();
assert_eq!(list["count"], 0);
}
#[test]
fn cancel_missing_trigger_errors() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_cancel_event_trigger(&json!({ "id": "nonexistent-id" }), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No trigger found"));
}
#[test]
fn schedule_missing_predicate_errors() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_schedule_event_trigger(
&json!({ "action": { "type": "mcp", "method": "get_version", "args": {} } }),
&ctx,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("predicate"));
}
#[test]
fn schedule_missing_action_errors() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_schedule_event_trigger(&json!({ "predicate": { "to_stage": "Done" } }), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("action"));
}
}