huskies: merge 1038
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user