feat(1001): recover_half_written_items MCP tool
Adds db::recover, a discovery + recovery layer for pipeline items that got half-written before the Stage 1 fix landed (content in content store + SQLite shadow, no live CRDT entry). For each orphan, the content body is re-anchored to a fresh non-tombstoned id and the old id's content row is cleared. Exposed as the recover_half_written_items MCP tool. dry_run defaults to true so the caller can review what would change before mutating. YAML front-matter parsing is hand-rolled and scoped to the three fields the create_*_file path emits (name, type, depends_on). It tolerates missing or malformed lines by falling back to safe defaults; the orphan is recovered with the best metadata we can pull from the body and the rest is left to the operator to fix up. The discovery step is read-only and idempotent. Recovery is also idempotent in the sense that once an orphan is lifted, the next discovery pass won't see it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,46 @@ pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// MCP tool: discover or recover half-written pipeline items (bug 1001).
|
||||
///
|
||||
/// A half-written item is one whose content row is in the content store /
|
||||
/// SQLite shadow but whose CRDT entry is absent or tombstoned. Such items
|
||||
/// are invisible to every CRDT-driven read path and to `delete_story` /
|
||||
/// `purge_story`, so they need explicit recovery.
|
||||
///
|
||||
/// With `dry_run = true` (default), returns the list of discovered half-
|
||||
/// writes without mutating anything. With `dry_run = false`, lifts each
|
||||
/// orphan onto a fresh non-tombstoned id and returns the old→new mapping.
|
||||
pub(crate) fn tool_recover_half_written_items(args: &Value) -> Result<String, String> {
|
||||
let dry_run = args
|
||||
.get("dry_run")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
if dry_run {
|
||||
let half = crate::db::find_half_written_items();
|
||||
return serde_json::to_string_pretty(&json!({
|
||||
"dry_run": true,
|
||||
"found": half,
|
||||
"count": half.len(),
|
||||
"message": format!(
|
||||
"Discovered {} half-written item(s). Re-run with dry_run=false to recover them.",
|
||||
half.len()
|
||||
),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"));
|
||||
}
|
||||
|
||||
let results = crate::db::recover_half_written_items();
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"dry_run": false,
|
||||
"recovered": results,
|
||||
"count": results.len(),
|
||||
"message": format!("Recovered {} half-written item(s).", results.len()),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// MCP tool: return the server version, build hash, and running port.
|
||||
pub(crate) fn tool_get_version(ctx: &AppContext) -> Result<String, String> {
|
||||
let build_hash =
|
||||
|
||||
@@ -93,6 +93,8 @@ pub async fn dispatch_tool_call(
|
||||
"purge_story" => story_tools::tool_purge_story(&args, ctx),
|
||||
// Debug CRDT dump (story 515)
|
||||
"dump_crdt" => diagnostics::tool_dump_crdt(&args),
|
||||
// Recover half-written pipeline items (bug 1001)
|
||||
"recover_half_written_items" => diagnostics::tool_recover_half_written_items(&args),
|
||||
// Read-only peer mesh diagnostics (story 720)
|
||||
"mesh_status" => diagnostics::tool_mesh_status(&args),
|
||||
// Arbitrary pipeline movement
|
||||
|
||||
@@ -96,6 +96,7 @@ mod tests {
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"loc_file"));
|
||||
assert!(names.contains(&"dump_crdt"));
|
||||
assert!(names.contains(&"recover_half_written_items"));
|
||||
assert!(names.contains(&"get_version"));
|
||||
assert!(names.contains(&"remove_criterion"));
|
||||
assert!(names.contains(&"mesh_status"));
|
||||
@@ -106,7 +107,7 @@ mod tests {
|
||||
assert!(names.contains(&"show_epic"));
|
||||
assert!(names.contains(&"freeze_story"));
|
||||
assert!(names.contains(&"unfreeze_story"));
|
||||
assert_eq!(tools.len(), 74);
|
||||
assert_eq!(tools.len(), 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -288,6 +288,20 @@ pub(super) fn system_tools() -> Vec<Value> {
|
||||
"required": []
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "recover_half_written_items",
|
||||
"description": "Discover (and optionally recover) half-written pipeline items — rows whose content is in the SQLite shadow and content store but whose CRDT entry is absent or tombstoned. These are invisible to every CRDT-driven read path and can't be cleaned up by delete_story / purge_story. Recovery moves each orphan's content onto a fresh non-tombstoned id and reports the old→new mapping. Defaults to dry_run=true so you can see what would change before mutating.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"description": "When true (default), only discover half-written items and report them. When false, perform the recovery."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "wizard_status",
|
||||
"description": "Return the current setup wizard state: which step is active, and which are done/skipped/pending. Use this to inspect progress before calling wizard_generate, wizard_confirm, wizard_skip, or wizard_retry.",
|
||||
|
||||
Reference in New Issue
Block a user