From 9bd3c10a09027f9b0be4f553da8afe54b8e09809 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 15:54:33 +0000 Subject: [PATCH] huskies: merge 872 --- server/src/chat/commands/depends.rs | 12 ++- server/src/crdt_state/mod.rs | 2 +- server/src/crdt_state/write.rs | 83 +++++++++++++++++++ server/src/http/mcp/story_tools/spike.rs | 12 ++- .../src/http/mcp/story_tools/story/update.rs | 13 +++ server/src/http/workflow/bug_ops/bug.rs | 3 + server/src/http/workflow/bug_ops/refactor.rs | 3 + server/src/http/workflow/bug_ops/spike.rs | 8 ++ server/src/http/workflow/bug_ops/tests.rs | 20 +++-- server/src/http/workflow/story_ops/create.rs | 3 + 10 files changed, 147 insertions(+), 12 deletions(-) diff --git a/server/src/chat/commands/depends.rs b/server/src/chat/commands/depends.rs index 35870e64..705671c8 100644 --- a/server/src/chat/commands/depends.rs +++ b/server/src/chat/commands/depends.rs @@ -81,6 +81,8 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option { .map(|i| i.stage.dir_name().to_string()) .unwrap_or_else(|| stage_dir.clone()); crate::db::write_item_with_content(&story_id, &stage, &updated); + // Sync depends_on to the typed CRDT register. + crate::crdt_state::set_depends_on(&story_id, &deps); if deps.is_empty() { Some(format!( "Cleared all dependencies for **{story_name}** ({story_id})." @@ -94,10 +96,14 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option { } } else { match write_depends_on(&path, &deps) { - Ok(()) if deps.is_empty() => Some(format!( - "Cleared all dependencies for **{story_name}** ({story_id})." - )), + Ok(()) if deps.is_empty() => { + crate::crdt_state::set_depends_on(&story_id, &[]); + Some(format!( + "Cleared all dependencies for **{story_name}** ({story_id})." + )) + } Ok(()) => { + crate::crdt_state::set_depends_on(&story_id, &deps); let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); Some(format!( "Set depends_on: [{}] for **{story_name}** ({story_id}).", diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index cd43b833..ce3aeea1 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -53,7 +53,7 @@ pub use types::{ }; pub use write::{ bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, - set_agent, set_qa_mode, set_retry_count, write_item, + set_agent, set_depends_on, set_qa_mode, set_retry_count, write_item, }; #[cfg(test)] diff --git a/server/src/crdt_state/write.rs b/server/src/crdt_state/write.rs index 5bc05f26..e7025f3e 100644 --- a/server/src/crdt_state/write.rs +++ b/server/src/crdt_state/write.rs @@ -186,6 +186,32 @@ pub fn migrate_names_from_slugs() { slog!("[crdt] Migrated names for {count} items from story ID slugs"); } +/// Set the typed `depends_on` CRDT register for a pipeline item. +/// +/// Encodes `deps` as a compact JSON array string (e.g. `"[837]"`) and writes it +/// to the item's `depends_on` register. An empty slice clears the register to an +/// empty string, which means "no dependencies". +/// +/// Returns `true` if the item was found and the op was applied, `false` otherwise. +pub fn set_depends_on(story_id: &str, deps: &[u32]) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.index.get(story_id) else { + return false; + }; + let value = if deps.is_empty() { + String::new() + } else { + serde_json::to_string(deps).unwrap_or_default() + }; + apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].depends_on.set(value)); + true +} + /// Set the `agent` field for a pipeline item by its story ID. /// /// `Some(name)` writes the agent name into the CRDT register. @@ -715,6 +741,63 @@ mod tests { migrate_names_from_slugs(); } + // ── set_depends_on regression tests ────────────────────────────────────── + + #[test] + fn set_depends_on_round_trip_and_clear() { + use super::super::read::{check_unmet_deps_crdt, read_item}; + init_for_test(); + + write_item( + "872_test_target", + "1_backlog", + Some("Target"), + None, + None, + None, + None, + None, + None, + None, + ); + + // Set depends_on to [837] and verify CRDT register holds the list. + let ok = set_depends_on("872_test_target", &[837]); + assert!(ok, "set_depends_on should return true for known item"); + let view = read_item("872_test_target").unwrap(); + assert_eq!( + view.depends_on, + Some(vec![837]), + "CRDT register should hold [837]" + ); + + // Clear by passing an empty slice. + let ok = set_depends_on("872_test_target", &[]); + assert!(ok, "set_depends_on([]) should return true"); + let view = read_item("872_test_target").unwrap(); + assert_eq!( + view.depends_on, None, + "clearing should leave register unset" + ); + + // Auto-assigner sees no unmet dependency after clearing. + let unmet = check_unmet_deps_crdt("872_test_target"); + assert!( + unmet.is_empty(), + "after clearing deps, auto-assigner should see no unmet dependencies" + ); + } + + #[test] + fn set_depends_on_returns_false_for_unknown_story() { + init_for_test(); + let ok = set_depends_on("nonexistent_story_872", &[1, 2, 3]); + assert!( + !ok, + "set_depends_on should return false for unknown story_id" + ); + } + // ── set_agent tests ────────────────────────────────────────────────────── #[test] diff --git a/server/src/http/mcp/story_tools/spike.rs b/server/src/http/mcp/story_tools/spike.rs index 3bf0d863..5d50c808 100644 --- a/server/src/http/mcp/story_tools/spike.rs +++ b/server/src/http/mcp/story_tools/spike.rs @@ -47,8 +47,18 @@ pub(crate) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result> = args + .get("depends_on") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let root = ctx.state.get_project_root()?; - let spike_id = create_spike_file(&root, name, description, &acceptance_criteria)?; + let spike_id = create_spike_file( + &root, + name, + description, + &acceptance_criteria, + depends_on.as_deref(), + )?; Ok(format!("Created spike: {spike_id}")) } diff --git a/server/src/http/mcp/story_tools/story/update.rs b/server/src/http/mcp/story_tools/story/update.rs index a62692a0..6c8745d0 100644 --- a/server/src/http/mcp/story_tools/story/update.rs +++ b/server/src/http/mcp/story_tools/story/update.rs @@ -38,6 +38,19 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result = arr + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u32)) + .collect(); + crate::crdt_state::set_depends_on(story_id, &dep_nums); + } else if deps_val.is_null() { + crate::crdt_state::set_depends_on(story_id, &[]); + } + } + let front_matter_opt = if front_matter.is_empty() { None } else { diff --git a/server/src/http/workflow/bug_ops/bug.rs b/server/src/http/workflow/bug_ops/bug.rs index 4f7a3df9..bdc23fd9 100644 --- a/server/src/http/workflow/bug_ops/bug.rs +++ b/server/src/http/workflow/bug_ops/bug.rs @@ -63,6 +63,9 @@ pub fn create_bug_file( // Write to database content store and CRDT. write_story_content(root, &bug_id, "1_backlog", &content); + // Sync depends_on to the typed CRDT register. + crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[])); + Ok(bug_id) } diff --git a/server/src/http/workflow/bug_ops/refactor.rs b/server/src/http/workflow/bug_ops/refactor.rs index d07428cc..03e67a8d 100644 --- a/server/src/http/workflow/bug_ops/refactor.rs +++ b/server/src/http/workflow/bug_ops/refactor.rs @@ -59,6 +59,9 @@ pub fn create_refactor_file( // Write to database content store and CRDT. write_story_content(root, &refactor_id, "1_backlog", &content); + // Sync depends_on to the typed CRDT register. + crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[])); + Ok(refactor_id) } diff --git a/server/src/http/workflow/bug_ops/spike.rs b/server/src/http/workflow/bug_ops/spike.rs index 90e929a3..d4c0851b 100644 --- a/server/src/http/workflow/bug_ops/spike.rs +++ b/server/src/http/workflow/bug_ops/spike.rs @@ -12,6 +12,7 @@ pub fn create_spike_file( name: &str, description: Option<&str>, acceptance_criteria: &[String], + depends_on: Option<&[u32]>, ) -> Result { let spike_number = next_item_number(root)?; let slug = slugify_name(name); @@ -26,6 +27,10 @@ pub fn create_spike_file( content.push_str("---\n"); content.push_str("type: spike\n"); content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); + if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { + let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); + content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); + } content.push_str("---\n\n"); content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); content.push_str("## Question\n\n"); @@ -58,5 +63,8 @@ pub fn create_spike_file( // Write to database content store and CRDT. write_story_content(root, &spike_id, "1_backlog", &content); + // Sync depends_on to the typed CRDT register. + crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[])); + Ok(spike_id) } diff --git a/server/src/http/workflow/bug_ops/tests.rs b/server/src/http/workflow/bug_ops/tests.rs index 1750533c..b565ef68 100644 --- a/server/src/http/workflow/bug_ops/tests.rs +++ b/server/src/http/workflow/bug_ops/tests.rs @@ -246,8 +246,14 @@ fn create_bug_file_uses_default_acceptance_criterion() { fn create_spike_file_writes_correct_content() { let tmp = tempfile::tempdir().unwrap(); - let spike_id = - create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None, &[]).unwrap(); + let spike_id = create_spike_file( + tmp.path(), + "Filesystem Watcher Architecture", + None, + &[], + None, + ) + .unwrap(); assert!( spike_id.chars().all(|c| c.is_ascii_digit()), @@ -278,7 +284,7 @@ fn create_spike_file_uses_description_when_provided() { let description = "What is the best approach for watching filesystem events?"; let spike_id = - create_spike_file(tmp.path(), "FS Watcher Spike", Some(description), &[]).unwrap(); + create_spike_file(tmp.path(), "FS Watcher Spike", Some(description), &[], None).unwrap(); let contents = crate::db::read_content(&spike_id) .or_else(|| { @@ -294,7 +300,7 @@ fn create_spike_file_uses_description_when_provided() { #[test] fn create_spike_file_uses_placeholder_when_no_description() { let tmp = tempfile::tempdir().unwrap(); - let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); + let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); let contents = crate::db::read_content(&spike_id) .or_else(|| { @@ -310,7 +316,7 @@ fn create_spike_file_uses_placeholder_when_no_description() { #[test] fn create_spike_file_rejects_empty_name() { let tmp = tempfile::tempdir().unwrap(); - let result = create_spike_file(tmp.path(), "!!!", None, &[]); + let result = create_spike_file(tmp.path(), "!!!", None, &[], None); assert!(result.is_err()); assert!(result.unwrap_err().contains("alphanumeric")); } @@ -319,7 +325,7 @@ fn create_spike_file_rejects_empty_name() { fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() { let tmp = tempfile::tempdir().unwrap(); let name = "Spike: compare \"fast\" vs slow encoders"; - let result = create_spike_file(tmp.path(), name, None, &[]); + let result = create_spike_file(tmp.path(), name, None, &[], None); assert!(result.is_ok(), "create_spike_file failed: {result:?}"); let spike_id = result.unwrap(); @@ -345,7 +351,7 @@ fn create_spike_file_increments_from_existing_items() { "---\nname: Existing\n---\n", ); - let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); + let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); assert!( spike_id.chars().all(|c| c.is_ascii_digit()), "spike ID must be numeric-only, got: {spike_id}" diff --git a/server/src/http/workflow/story_ops/create.rs b/server/src/http/workflow/story_ops/create.rs index ed19f0b5..62866003 100644 --- a/server/src/http/workflow/story_ops/create.rs +++ b/server/src/http/workflow/story_ops/create.rs @@ -68,6 +68,9 @@ pub fn create_story_file( // Write to database content store and CRDT. write_story_content(root, &story_id, "1_backlog", &content); + // Sync depends_on to the typed CRDT register. + crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[])); + Ok(story_id) }