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:
Timmy
2026-05-13 19:16:05 +01:00
parent c61f715878
commit cd411ba443
6 changed files with 452 additions and 1 deletions
+40
View File
@@ -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 =