huskies: merge 515_story_add_a_debug_mcp_tool_to_dump_the_in_memory_crdt_state_for_inspection

This commit is contained in:
dave
2026-04-10 10:20:41 +00:00
parent c6b6be872b
commit f015fe5a1d
5 changed files with 353 additions and 1 deletions
+84
View File
@@ -265,6 +265,45 @@ pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String,
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: dump the raw in-memory CRDT state for debugging.
///
/// **Debug tool only** — for normal pipeline introspection use `get_pipeline_status`.
pub(super) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
let story_id_filter = args.get("story_id").and_then(|v| v.as_str());
let dump = crate::crdt_state::dump_crdt_state(story_id_filter);
let items: Vec<Value> = dump
.items
.into_iter()
.map(|item| {
json!({
"story_id": item.story_id,
"stage": item.stage,
"name": item.name,
"agent": item.agent,
"retry_count": item.retry_count,
"blocked": item.blocked,
"depends_on": item.depends_on,
"content_index": item.content_index,
"is_deleted": item.is_deleted,
})
})
.collect();
serde_json::to_string_pretty(&json!({
"metadata": {
"in_memory_state_loaded": dump.in_memory_state_loaded,
"total_items": dump.total_items,
"total_ops_in_list": dump.total_ops_in_list,
"max_seq_in_list": dump.max_seq_in_list,
"persisted_ops_count": dump.persisted_ops_count,
"pending_persist_ops_count": null,
},
"items": items,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: count lines in a specific file relative to the project root.
pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
let file_path = args
@@ -751,4 +790,49 @@ mod tests {
.contains("not found in any pipeline stage")
);
}
// ── dump_crdt tool tests ──────────────────────────────────────────
#[test]
fn tool_dump_crdt_returns_valid_json() {
let result = tool_dump_crdt(&json!({})).unwrap();
let parsed: Value = serde_json::from_str(&result).expect("result must be valid JSON");
assert!(parsed["metadata"].is_object(), "must have metadata object");
assert!(parsed["items"].is_array(), "must have items array");
}
#[test]
fn tool_dump_crdt_metadata_has_required_fields() {
let result = tool_dump_crdt(&json!({})).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let meta = &parsed["metadata"];
assert!(meta["in_memory_state_loaded"].is_boolean());
assert!(meta["total_items"].is_number());
assert!(meta["total_ops_in_list"].is_number());
assert!(meta["max_seq_in_list"].is_number());
assert!(meta["persisted_ops_count"].is_number());
}
#[test]
fn tool_dump_crdt_with_story_id_filter_returns_valid_json() {
let result =
tool_dump_crdt(&json!({"story_id": "9999_story_nonexistent"})).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert!(parsed["items"].as_array().unwrap().is_empty());
}
#[test]
fn dump_crdt_in_tools_list() {
use super::super::handle_tools_list;
let resp = handle_tools_list(Some(json!(1)));
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
let tool = tools.iter().find(|t| t["name"] == "dump_crdt");
assert!(tool.is_some(), "dump_crdt missing from tools list");
let t = tool.unwrap();
assert!(
t["description"].as_str().unwrap().to_lowercase().contains("debug"),
"description must mention this is a debug tool"
);
assert!(t["inputSchema"].is_object());
}
}
+18 -1
View File
@@ -1017,6 +1017,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id"]
}
},
{
"name": "dump_crdt",
"description": "DEBUG TOOL: Dump the raw in-memory CRDT state. Returns every item the running server knows about, including tombstoned (deleted) entries, with internal op metadata (content_index, is_deleted, stage, etc.). Use this when diagnosing CRDT/state divergence — NOT for normal pipeline introspection (use get_pipeline_status for that). Optional story_id filter returns a single item.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Optional: restrict output to this single work item identifier (filename stem, e.g. '42_story_my_feature')"
}
},
"required": []
}
},
{
"name": "move_story",
"description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.",
@@ -1333,6 +1347,8 @@ async fn handle_tools_call(
"delete_story" => story_tools::tool_delete_story(&args, ctx).await,
// Purge story (CRDT tombstone — story 521)
"purge_story" => story_tools::tool_purge_story(&args, ctx),
// Debug CRDT dump (story 515)
"dump_crdt" => diagnostics::tool_dump_crdt(&args),
// Arbitrary pipeline movement
"move_story" => diagnostics::tool_move_story(&args, ctx),
// Unblock story
@@ -1471,7 +1487,8 @@ mod tests {
assert!(names.contains(&"git_log"));
assert!(names.contains(&"status"));
assert!(names.contains(&"loc_file"));
assert_eq!(tools.len(), 58);
assert!(names.contains(&"dump_crdt"));
assert_eq!(tools.len(), 59);
}
#[test]