feat(reconciliation): implement deterministic state reconciliation engine and post-commit hook
- Implement ReconciliationEngine in server/src/handlers/reconciliation.rs - Auto-transition ADRs to implemented, resolve tech debts, and cascade task unblocking - Ingest git commit events via POST /api/git/commit and scripts/git-reconcile.py post-commit hook - Add universal pagination, search filtering, and keyboard navigation to dashboard - Implement non-destructive task TTL expiry sweeper and gate verification - Implements: ADR-0102, ADR-0103
This commit is contained in:
1 parent
ec977c24dd
commit
8f924b793a
16 files changed
+2622
-163
No files matched your search
@@ -0,0 +1,541 @@
|
||||
use crate::models::{Adr, TaskEvent};
|
||||
use crate::state::MemoryState;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
|
||||
pub struct GitCommitPayload {
|
||||
pub commit: String,
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub repo_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct ReconciliationResult {
|
||||
pub implemented_adrs: Vec<String>,
|
||||
pub resolved_tech_debts: Vec<String>,
|
||||
pub completed_tasks: Vec<String>,
|
||||
pub unblocked_tasks: Vec<String>,
|
||||
pub updated_milestones: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extracts ADR identifiers such as "ADR-0102", "ADR-102", "adr-005", "ADR: 102"
|
||||
pub fn extract_adrs(text: &str) -> Vec<String> {
|
||||
let mut adrs = Vec::new();
|
||||
let lower = text.to_lowercase();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let lower_chars: Vec<char> = lower.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
// Look for "adr"
|
||||
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
|
||||
let mut cursor = i + 3;
|
||||
// Skip optional '-', ':', or whitespace
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
cursor += 1;
|
||||
}
|
||||
// Collect consecutive digits
|
||||
let digit_start = cursor;
|
||||
while cursor < len && chars[cursor].is_ascii_digit() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor > digit_start {
|
||||
let num_str: String = chars[digit_start..cursor].iter().collect();
|
||||
if let Ok(num) = num_str.parse::<u32>() {
|
||||
let formatted = format!("ADR-{:04}", num);
|
||||
if !adrs.contains(&formatted) {
|
||||
adrs.push(formatted);
|
||||
}
|
||||
let raw_formatted = format!("ADR-{}", num_str);
|
||||
if !adrs.contains(&raw_formatted) {
|
||||
adrs.push(raw_formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
i = cursor;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
adrs
|
||||
}
|
||||
|
||||
/// Extracts TechDebt identifiers such as "TD-001", "td-auth-cleanup", "TD: 12"
|
||||
pub fn extract_tech_debts(text: &str) -> Vec<String> {
|
||||
let mut debts = Vec::new();
|
||||
let lower = text.to_lowercase();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let lower_chars: Vec<char> = lower.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
|
||||
let mut cursor = i + 2;
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
cursor += 1;
|
||||
}
|
||||
let start = cursor;
|
||||
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor > start {
|
||||
let id_part: String = chars[start..cursor].iter().collect();
|
||||
let full_id = format!("TD-{}", id_part);
|
||||
if !debts.contains(&full_id) {
|
||||
debts.push(full_id);
|
||||
}
|
||||
if !debts.contains(&id_part) {
|
||||
debts.push(id_part);
|
||||
}
|
||||
}
|
||||
i = cursor;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
debts
|
||||
}
|
||||
|
||||
/// Extracts Task identifiers such as "TASK-123", "task-01", or UUID strings
|
||||
pub fn extract_tasks(text: &str) -> Vec<String> {
|
||||
let mut tasks = Vec::new();
|
||||
let lower = text.to_lowercase();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let lower_chars: Vec<char> = lower.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
|
||||
let mut cursor = i + 4;
|
||||
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
|
||||
cursor += 1;
|
||||
}
|
||||
let start = cursor;
|
||||
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor > start {
|
||||
let id_part: String = chars[start..cursor].iter().collect();
|
||||
let full_id = format!("TASK-{}", id_part);
|
||||
if !tasks.contains(&full_id) {
|
||||
tasks.push(full_id);
|
||||
}
|
||||
if !tasks.contains(&id_part) {
|
||||
tasks.push(id_part);
|
||||
}
|
||||
}
|
||||
i = cursor;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
tasks
|
||||
}
|
||||
|
||||
fn adr_numeric_id(id: &str) -> Option<u32> {
|
||||
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Automatically cascades unblocking to tasks whose dependencies have all completed.
|
||||
pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
|
||||
let mut unblocked = Vec::new();
|
||||
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||
|
||||
state.project.tasks.modify(|tasks| {
|
||||
for t in tasks.iter_mut() {
|
||||
if t.status.eq_ignore_ascii_case("blocked") {
|
||||
if t.dependencies.is_empty() {
|
||||
t.status = "active".to_string();
|
||||
t.updated_at = crate::handlers::utils::now_secs();
|
||||
unblocked.push(t.id.clone());
|
||||
} else {
|
||||
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
|
||||
tasks_snapshot.iter().any(|other| {
|
||||
(other.id == *dep_id || other.title == *dep_id)
|
||||
&& !other.is_active()
|
||||
})
|
||||
});
|
||||
if all_deps_completed {
|
||||
t.status = "active".to_string();
|
||||
t.updated_at = crate::handlers::utils::now_secs();
|
||||
unblocked.push(t.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for tid in &unblocked {
|
||||
state.record_activity(
|
||||
"task_update",
|
||||
&format!("Auto-unblocked Task {} (all dependencies met)", tid),
|
||||
Some("active"),
|
||||
);
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: tid.clone(),
|
||||
status: "active".to_string(),
|
||||
action: Some("cascade_unblock".to_string()),
|
||||
result: Some(serde_json::json!({ "status": "active" })),
|
||||
error: None,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
unblocked
|
||||
}
|
||||
|
||||
/// Automatically recalculates milestone progress and status based on task completion.
|
||||
pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
|
||||
let mut updated = Vec::new();
|
||||
let tasks_snapshot = state.project.tasks.read_with(|tasks| tasks.clone());
|
||||
|
||||
state.project.milestones.modify(|milestones| {
|
||||
for m in milestones.iter_mut() {
|
||||
if m.task_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let total = m.task_ids.len();
|
||||
let completed = m
|
||||
.task_ids
|
||||
.iter()
|
||||
.filter(|tid| {
|
||||
tasks_snapshot
|
||||
.iter()
|
||||
.any(|t| (t.id == **tid || t.title == **tid) && !t.is_active())
|
||||
})
|
||||
.count();
|
||||
|
||||
if completed == total {
|
||||
if !m.status.eq_ignore_ascii_case("completed") {
|
||||
m.status = "completed".to_string();
|
||||
if m.completed_at.is_none() {
|
||||
m.completed_at = Some(crate::handlers::utils::now_secs());
|
||||
}
|
||||
updated.push(m.id.clone());
|
||||
}
|
||||
} else if completed > 0
|
||||
|| tasks_snapshot.iter().any(|t| {
|
||||
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
|
||||
&& (t.status == "in_progress" || t.status == "active")
|
||||
})
|
||||
{
|
||||
if !m.status.eq_ignore_ascii_case("in_progress")
|
||||
&& !m.status.eq_ignore_ascii_case("completed")
|
||||
{
|
||||
m.status = "in_progress".to_string();
|
||||
updated.push(m.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for mid in &updated {
|
||||
state.record_activity(
|
||||
"milestone",
|
||||
&format!("Milestone {} status updated via task progress", mid),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
updated
|
||||
}
|
||||
|
||||
/// Reconciles state deterministically from git commit messages or MCP `log_code_change` calls.
|
||||
pub async fn reconcile_commit_or_code_change(
|
||||
state: &Arc<MemoryState>,
|
||||
text: &str,
|
||||
file_path: Option<&str>,
|
||||
git_commit: Option<&str>,
|
||||
git_branch: Option<&str>,
|
||||
) -> ReconciliationResult {
|
||||
let mut result = ReconciliationResult::default();
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
// 1. Reconcile ADRs
|
||||
let adr_candidates = extract_adrs(text);
|
||||
if !adr_candidates.is_empty() {
|
||||
let mut adrs_to_index: Vec<Adr> = Vec::new();
|
||||
state.code.adrs.modify(|adrs| {
|
||||
for adr in adrs.iter_mut() {
|
||||
let matches = adr_candidates.iter().any(|cand| {
|
||||
if adr.id.eq_ignore_ascii_case(cand) {
|
||||
return true;
|
||||
}
|
||||
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) {
|
||||
if n1 == n2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
if matches && !adr.status.eq_ignore_ascii_case("implemented") {
|
||||
adr.status = "implemented".to_string();
|
||||
adr.resolved_at = Some(now);
|
||||
if let Some(rev) = git_commit {
|
||||
adr.git_commit = Some(rev.to_string());
|
||||
}
|
||||
if let Some(br) = git_branch {
|
||||
adr.git_branch = Some(br.to_string());
|
||||
}
|
||||
result.implemented_adrs.push(adr.id.clone());
|
||||
adrs_to_index.push(adr.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !adrs_to_index.is_empty() {
|
||||
let idx = state.get_search_index().await;
|
||||
for a in &adrs_to_index {
|
||||
drop(idx.index_adr(a));
|
||||
state.record_activity(
|
||||
"decision",
|
||||
&format!("Auto-implemented ADR {}: {}", a.id, a.title),
|
||||
Some("implemented"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reconcile Tech Debts
|
||||
let td_candidates = extract_tech_debts(text);
|
||||
let mentions_debt_resolution = text_lower.contains("fix")
|
||||
|| text_lower.contains("resolve")
|
||||
|| text_lower.contains("refactor")
|
||||
|| text_lower.contains("clean")
|
||||
|| text_lower.contains("address");
|
||||
|
||||
state.code.tech_debts.modify(|debts| {
|
||||
for d in debts.iter_mut() {
|
||||
if d.is_resolved {
|
||||
continue;
|
||||
}
|
||||
let matches_id = td_candidates.iter().any(|cand| d.id.eq_ignore_ascii_case(cand));
|
||||
let matches_file = file_path
|
||||
.map(|fp| d.file_path.as_deref() == Some(fp))
|
||||
.unwrap_or(false)
|
||||
&& mentions_debt_resolution
|
||||
&& (text_lower.contains("debt") || text_lower.contains("td-"));
|
||||
|
||||
if matches_id || matches_file {
|
||||
d.is_resolved = true;
|
||||
if let Some(rev) = git_commit {
|
||||
d.git_commit = Some(rev.to_string());
|
||||
}
|
||||
if let Some(br) = git_branch {
|
||||
d.git_branch = Some(br.to_string());
|
||||
}
|
||||
result.resolved_tech_debts.push(d.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for tdid in &result.resolved_tech_debts {
|
||||
state.record_activity(
|
||||
"tech_debt",
|
||||
&format!("Auto-resolved TechDebt {} via commit", tdid),
|
||||
Some("resolved"),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reconcile Tasks
|
||||
let task_candidates = extract_tasks(text);
|
||||
let mentions_task_close = text_lower.contains("fix")
|
||||
|| text_lower.contains("close")
|
||||
|| text_lower.contains("complete")
|
||||
|| text_lower.contains("resolve")
|
||||
|| text_lower.contains("finish")
|
||||
|| text_lower.contains("implement");
|
||||
|
||||
if mentions_task_close && !task_candidates.is_empty() {
|
||||
state.project.tasks.modify(|tasks| {
|
||||
for t in tasks.iter_mut() {
|
||||
if !t.is_active() {
|
||||
continue;
|
||||
}
|
||||
let matches = task_candidates.iter().any(|cand| {
|
||||
t.id.eq_ignore_ascii_case(cand)
|
||||
|| cand.trim_start_matches("TASK-").eq_ignore_ascii_case(&t.id)
|
||||
});
|
||||
if matches {
|
||||
t.status = "completed".to_string();
|
||||
t.updated_at = now;
|
||||
result.completed_tasks.push(t.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for tid in &result.completed_tasks {
|
||||
state.record_activity(
|
||||
"task_update",
|
||||
&format!("Auto-completed Task {} via commit", tid),
|
||||
Some("completed"),
|
||||
);
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: tid.clone(),
|
||||
status: "completed".to_string(),
|
||||
action: Some("commit_reconciliation".to_string()),
|
||||
result: Some(serde_json::json!({ "status": "completed" })),
|
||||
error: None,
|
||||
timestamp: now,
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cascade Unblock & Recalculate Milestones if any tasks completed
|
||||
if !result.completed_tasks.is_empty() {
|
||||
result.unblocked_tasks = cascade_unblock_tasks(state);
|
||||
result.updated_milestones = recalculate_milestones(state);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{Milestone, Task};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_extract_adrs() {
|
||||
let text = "feat(api): implement ADR-0102 and ADR-103 with pagination";
|
||||
let adrs = extract_adrs(text);
|
||||
assert!(adrs.contains(&"ADR-0102".to_string()));
|
||||
assert!(adrs.contains(&"ADR-0103".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tech_debts() {
|
||||
let text = "refactor: resolve TD-cleanup_old_buffers and TD-002";
|
||||
let debts = extract_tech_debts(text);
|
||||
assert!(debts.contains(&"TD-cleanup_old_buffers".to_string()));
|
||||
assert!(debts.contains(&"TD-002".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tasks() {
|
||||
let text = "fix(core): close task-551 and TASK-abc";
|
||||
let tasks = extract_tasks(text);
|
||||
assert!(tasks.contains(&"TASK-551".to_string()));
|
||||
assert!(tasks.contains(&"TASK-abc".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reconcile_adr_implementation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
state.code.adrs.modify(|adrs| {
|
||||
adrs.push(Adr {
|
||||
id: "ADR-0102".to_string(),
|
||||
title: "Dynamic Neovim Projection Buffer".to_string(),
|
||||
status: "accepted".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
let result = reconcile_commit_or_code_change(
|
||||
&state,
|
||||
"feat: implement ADR-102 projection buffer",
|
||||
None,
|
||||
Some("commit123"),
|
||||
Some("master"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.implemented_adrs, vec!["ADR-0102"]);
|
||||
|
||||
let adr = state.code.adrs.read_with(|adrs| adrs[0].clone());
|
||||
assert_eq!(adr.status, "implemented");
|
||||
assert_eq!(adr.git_commit.as_deref(), Some("commit123"));
|
||||
assert_eq!(adr.git_branch.as_deref(), Some("master"));
|
||||
assert!(adr.resolved_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cascade_unblock_and_milestone_recalculation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let task1 = Task {
|
||||
id: "task-1".to_string(),
|
||||
title: "Task 1".to_string(),
|
||||
status: "active".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let task2 = Task {
|
||||
id: "task-2".to_string(),
|
||||
title: "Task 2".to_string(),
|
||||
status: "blocked".to_string(),
|
||||
dependencies: vec!["task-1".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
state.project.tasks.modify(|t| {
|
||||
t.push(task1);
|
||||
t.push(task2);
|
||||
});
|
||||
|
||||
let ms = Milestone {
|
||||
id: "ms-1".to_string(),
|
||||
title: "Sprint 1".to_string(),
|
||||
status: "pending".to_string(),
|
||||
task_ids: vec!["task-1".to_string(), "task-2".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
state.project.milestones.modify(|m| m.push(ms));
|
||||
|
||||
// Complete task-1 via commit message
|
||||
let result = reconcile_commit_or_code_change(
|
||||
&state,
|
||||
"fix: close task-1 core functionality",
|
||||
None,
|
||||
Some("c1"),
|
||||
Some("master"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.completed_tasks, vec!["task-1"]);
|
||||
assert_eq!(result.unblocked_tasks, vec!["task-2"]);
|
||||
assert_eq!(result.updated_milestones, vec!["ms-1"]);
|
||||
|
||||
// Task 2 should now be active
|
||||
let t2 = state.project.tasks.read_with(|t| t[1].clone());
|
||||
assert_eq!(t2.status, "active");
|
||||
|
||||
// Milestone should now be in_progress
|
||||
let m = state.project.milestones.read_with(|m| m[0].clone());
|
||||
assert_eq!(m.status, "in_progress");
|
||||
|
||||
// Now complete task-2
|
||||
let result2 = reconcile_commit_or_code_change(
|
||||
&state,
|
||||
"fix: close task-2 follow-up",
|
||||
None,
|
||||
Some("c2"),
|
||||
Some("master"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result2.completed_tasks, vec!["task-2"]);
|
||||
let m2 = state.project.milestones.read_with(|m| m[0].clone());
|
||||
assert_eq!(m2.status, "completed");
|
||||
assert!(m2.completed_at.is_some());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user