huskies: merge 872

This commit is contained in:
dave
2026-04-29 15:54:33 +00:00
parent 7505f7fdeb
commit 9bd3c10a09
10 changed files with 147 additions and 12 deletions
+8 -2
View File
@@ -81,6 +81,8 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
.map(|i| i.stage.dir_name().to_string()) .map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| stage_dir.clone()); .unwrap_or_else(|| stage_dir.clone());
crate::db::write_item_with_content(&story_id, &stage, &updated); 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() { if deps.is_empty() {
Some(format!( Some(format!(
"Cleared all dependencies for **{story_name}** ({story_id})." "Cleared all dependencies for **{story_name}** ({story_id})."
@@ -94,10 +96,14 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
} }
} else { } else {
match write_depends_on(&path, &deps) { match write_depends_on(&path, &deps) {
Ok(()) if deps.is_empty() => Some(format!( Ok(()) if deps.is_empty() => {
crate::crdt_state::set_depends_on(&story_id, &[]);
Some(format!(
"Cleared all dependencies for **{story_name}** ({story_id})." "Cleared all dependencies for **{story_name}** ({story_id})."
)), ))
}
Ok(()) => { Ok(()) => {
crate::crdt_state::set_depends_on(&story_id, &deps);
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
Some(format!( Some(format!(
"Set depends_on: [{}] for **{story_name}** ({story_id}).", "Set depends_on: [{}] for **{story_name}** ({story_id}).",
+1 -1
View File
@@ -53,7 +53,7 @@ pub use types::{
}; };
pub use write::{ pub use write::{
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, 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)] #[cfg(test)]
+83
View File
@@ -186,6 +186,32 @@ pub fn migrate_names_from_slugs() {
slog!("[crdt] Migrated names for {count} items from story ID 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. /// Set the `agent` field for a pipeline item by its story ID.
/// ///
/// `Some(name)` writes the agent name into the CRDT register. /// `Some(name)` writes the agent name into the CRDT register.
@@ -715,6 +741,63 @@ mod tests {
migrate_names_from_slugs(); 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 ────────────────────────────────────────────────────── // ── set_agent tests ──────────────────────────────────────────────────────
#[test] #[test]
+11 -1
View File
@@ -47,8 +47,18 @@ pub(crate) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result<String
); );
} }
let depends_on: Option<Vec<u32>> = args
.get("depends_on")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let root = ctx.state.get_project_root()?; 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}")) Ok(format!("Created spike: {spike_id}"))
} }
@@ -38,6 +38,19 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
crate::crdt_state::set_qa_mode(story_id, mode); crate::crdt_state::set_qa_mode(story_id, mode);
} }
// Sync `depends_on` to the typed CRDT register when present in the update.
if let Some(deps_val) = front_matter.get("depends_on") {
if let Some(arr) = deps_val.as_array() {
let dep_nums: Vec<u32> = 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() { let front_matter_opt = if front_matter.is_empty() {
None None
} else { } else {
+3
View File
@@ -63,6 +63,9 @@ pub fn create_bug_file(
// Write to database content store and CRDT. // Write to database content store and CRDT.
write_story_content(root, &bug_id, "1_backlog", &content); 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) Ok(bug_id)
} }
@@ -59,6 +59,9 @@ pub fn create_refactor_file(
// Write to database content store and CRDT. // Write to database content store and CRDT.
write_story_content(root, &refactor_id, "1_backlog", &content); 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) Ok(refactor_id)
} }
@@ -12,6 +12,7 @@ pub fn create_spike_file(
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
acceptance_criteria: &[String], acceptance_criteria: &[String],
depends_on: Option<&[u32]>,
) -> Result<String, String> { ) -> Result<String, String> {
let spike_number = next_item_number(root)?; let spike_number = next_item_number(root)?;
let slug = slugify_name(name); let slug = slugify_name(name);
@@ -26,6 +27,10 @@ pub fn create_spike_file(
content.push_str("---\n"); content.push_str("---\n");
content.push_str("type: spike\n"); content.push_str("type: spike\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) {
let nums: Vec<String> = 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("---\n\n");
content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); content.push_str(&format!("# Spike {spike_number}: {name}\n\n"));
content.push_str("## Question\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 to database content store and CRDT.
write_story_content(root, &spike_id, "1_backlog", &content); 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) Ok(spike_id)
} }
+13 -7
View File
@@ -246,8 +246,14 @@ fn create_bug_file_uses_default_acceptance_criterion() {
fn create_spike_file_writes_correct_content() { fn create_spike_file_writes_correct_content() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let spike_id = let spike_id = create_spike_file(
create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None, &[]).unwrap(); tmp.path(),
"Filesystem Watcher Architecture",
None,
&[],
None,
)
.unwrap();
assert!( assert!(
spike_id.chars().all(|c| c.is_ascii_digit()), 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 description = "What is the best approach for watching filesystem events?";
let spike_id = 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) let contents = crate::db::read_content(&spike_id)
.or_else(|| { .or_else(|| {
@@ -294,7 +300,7 @@ fn create_spike_file_uses_description_when_provided() {
#[test] #[test]
fn create_spike_file_uses_placeholder_when_no_description() { fn create_spike_file_uses_placeholder_when_no_description() {
let tmp = tempfile::tempdir().unwrap(); 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) let contents = crate::db::read_content(&spike_id)
.or_else(|| { .or_else(|| {
@@ -310,7 +316,7 @@ fn create_spike_file_uses_placeholder_when_no_description() {
#[test] #[test]
fn create_spike_file_rejects_empty_name() { fn create_spike_file_rejects_empty_name() {
let tmp = tempfile::tempdir().unwrap(); 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.is_err());
assert!(result.unwrap_err().contains("alphanumeric")); 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() { fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = "Spike: compare \"fast\" vs slow encoders"; 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:?}"); assert!(result.is_ok(), "create_spike_file failed: {result:?}");
let spike_id = result.unwrap(); let spike_id = result.unwrap();
@@ -345,7 +351,7 @@ fn create_spike_file_increments_from_existing_items() {
"---\nname: Existing\n---\n", "---\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!( assert!(
spike_id.chars().all(|c| c.is_ascii_digit()), spike_id.chars().all(|c| c.is_ascii_digit()),
"spike ID must be numeric-only, got: {spike_id}" "spike ID must be numeric-only, got: {spike_id}"
@@ -68,6 +68,9 @@ pub fn create_story_file(
// Write to database content store and CRDT. // Write to database content store and CRDT.
write_story_content(root, &story_id, "1_backlog", &content); 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) Ok(story_id)
} }