feat(929): delete db/yaml_legacy.rs entirely — CRDT is the sole source of truth

Final 929 sweep: every YAML-shaped helper is gone. No production code
parses or writes YAML front matter anywhere.

Surface removed:
- db/yaml_legacy.rs (FrontMatter/StoryMetadata structs, parse_front_matter,
  set_front_matter_field, yaml_residue marker) — file deleted.
- ItemMeta::from_yaml — deleted; callers pass typed ItemMeta::named(...) or
  ItemMeta::default() and use typed CRDT setters (set_depends_on,
  set_blocked, set_retry_count, set_agent, set_qa_mode, set_review_hold,
  set_item_type, set_epic, set_mergemaster_attempted) for the rest.
- write_coverage_baseline_to_story_file + read_coverage_percent_from_json —
  the coverage_baseline YAML field was write-only (nothing read it back);
  removed along with its caller in agent_tools/lifecycle.rs.
- update_story_in_file's generic `front_matter` HashMap parameter —
  tool_update_story now intercepts every known field name and routes it
  to a typed CRDT setter; unknown keys are rejected with an explicit error
  pointing at the typed setters. The function only takes user_story /
  description sections now.
- All 117 ItemMeta::from_yaml callsites migrated. Where tests previously
  passed a YAML-shaped content blob and relied on the helper to extract
  name/depends_on/blocked/agent/qa, they now pass:
    write_item_with_content(id, stage, content, ItemMeta::named("Foo"))
    crate::crdt_state::set_depends_on(id, &[...])    // when needed
    crate::crdt_state::set_blocked(id, true)         // when needed
    crate::crdt_state::set_agent(id, Some("..."))    // when needed
- write_story_content + write_story_file (test helper) now take an
  explicit `name: Option<&str>` instead of parsing it from content.
- db::ops::move_item_stage stopped re-parsing YAML on every stage
  transition; metadata is read straight from the CRDT view when mirroring
  the row into SQLite.

New CRDT setters added for symmetry:
- crdt_state::set_name (mirrors set_agent — explicit name updates).

cargo fmt --check, clippy --all-targets -- -D warnings, and the
2830-test suite all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 20:55:25 +01:00
parent 6c62e0fa31
commit 69d91d7707
58 changed files with 433 additions and 1344 deletions
@@ -293,9 +293,10 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9932_story_waiting", "9932_story_waiting",
"2_current", "2_current",
"---\nname: Waiting\ndepends_on: [9999]\n---\n", "# Waiting\n",
crate::db::ItemMeta::from_yaml("---\nname: Waiting\ndepends_on: [9999]\n---\n"), crate::db::ItemMeta::named("Waiting"),
); );
crate::crdt_state::set_depends_on("9932_story_waiting", &[9999]);
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(root).await; pool.auto_assign_available_work(root).await;
@@ -332,15 +333,16 @@ mod tests {
"999_story_dep", "999_story_dep",
"5_done", "5_done",
"---\nname: Dep\n---\n", "---\nname: Dep\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Dep\n---\n"), crate::db::ItemMeta::named("Dep"),
); );
// Story 10 depends on 999 which is done. // Story 10 depends on 999 which is done.
crate::db::write_item_with_content( crate::db::write_item_with_content(
"10_story_unblocked", "10_story_unblocked",
"2_current", "2_current",
"---\nname: Unblocked\ndepends_on: [999]\n---\n", "# Unblocked\n",
crate::db::ItemMeta::from_yaml("---\nname: Unblocked\ndepends_on: [999]\n---\n"), crate::db::ItemMeta::named("Unblocked"),
); );
crate::crdt_state::set_depends_on("10_story_unblocked", &[999]);
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(root).await; pool.auto_assign_available_work(root).await;
@@ -523,10 +525,8 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9860_story_conflict", "9860_story_conflict",
"4_merge_failure", "4_merge_failure",
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n", "CONFLICT (content): server/src/lib.rs",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Conflict"),
"---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n",
),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -561,10 +561,8 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9861_story_nothing", "9861_story_nothing",
"4_merge_failure", "4_merge_failure",
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n", "nothing to commit, working tree clean",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Nothing"),
"---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n",
),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -599,10 +597,12 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9863_story_blocked_conflict", "9863_story_blocked_conflict",
"4_merge", "4_merge",
"---\nname: Blocked conflict\nmerge_failure: \"CONFLICT (content): foo.rs\"\nblocked: true\n---\n", "CONFLICT (content): foo.rs",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta {
"---\nname: Blocked conflict\nmerge_failure: \"CONFLICT (content): foo.rs\"\nblocked: true\n---\n", name: Some("Blocked conflict".to_string()),
), blocked: Some(true),
..Default::default()
},
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -636,11 +636,10 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9862_story_attempted", "9862_story_attempted",
"4_merge", "4_merge",
"---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n", "CONFLICT (content): foo.rs",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Already tried"),
"---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n",
),
); );
crate::crdt_state::set_mergemaster_attempted("9862_story_attempted", true);
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await; pool.auto_assign_available_work(tmp.path()).await;
@@ -677,10 +676,8 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"920_story_transient", "920_story_transient",
"4_merge_failure", "4_merge_failure",
"---\nname: Transient\nmerge_failure: \"CONFLICT (content): foo.rs\"\n---\n", "CONFLICT (content): foo.rs",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Transient"),
"---\nname: Transient\nmerge_failure: \"CONFLICT (content): foo.rs\"\n---\n",
),
); );
// Simulate two previous transient exits (below cap of 3) recorded in DB. // Simulate two previous transient exits (below cap of 3) recorded in DB.
crate::db::write_content("920_story_transient:mergemaster_spawn_count", "2"); crate::db::write_content("920_story_transient:mergemaster_spawn_count", "2");
@@ -719,10 +716,8 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"920_story_genuine", "920_story_genuine",
"4_merge_failure", "4_merge_failure",
"---\nname: Genuine\nmerge_failure: \"CONFLICT (content): bar.rs\"\n---\n", "CONFLICT (content): bar.rs",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Genuine"),
"---\nname: Genuine\nmerge_failure: \"CONFLICT (content): bar.rs\"\n---\n",
),
); );
// The CRDT register is the sole authority; set it explicitly as the // The CRDT register is the sole authority; set it explicitly as the
// spawn exit path would after report_merge_failure. // spawn exit path would after report_merge_failure.
+4 -4
View File
@@ -168,7 +168,7 @@ mod tests {
"9970_story_archived", "9970_story_archived",
"6_archived", "6_archived",
"---\nname: Archived\n---\n", "---\nname: Archived\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Archived\n---\n"), crate::db::ItemMeta::named("Archived"),
); );
// Also place a stale .md file in a temp 1_backlog/ dir. // Also place a stale .md file in a temp 1_backlog/ dir.
@@ -205,19 +205,19 @@ mod tests {
"9942_story_foo", "9942_story_foo",
"2_current", "2_current",
"---\nname: foo\n---", "---\nname: foo\n---",
crate::db::ItemMeta::from_yaml("---\nname: foo\n---"), crate::db::ItemMeta::named("foo"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9940_story_bar", "9940_story_bar",
"2_current", "2_current",
"---\nname: bar\n---", "---\nname: bar\n---",
crate::db::ItemMeta::from_yaml("---\nname: bar\n---"), crate::db::ItemMeta::named("bar"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9935_story_baz", "9935_story_baz",
"2_current", "2_current",
"---\nname: baz\n---", "---\nname: baz\n---",
crate::db::ItemMeta::from_yaml("---\nname: baz\n---"), crate::db::ItemMeta::named("baz"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -176,7 +176,7 @@ async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() {
"173_story_test", "173_story_test",
"2_current", "2_current",
"---\nname: test\n---\n", "---\nname: test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: test\n---\n"), crate::db::ItemMeta::named("test"),
); );
// Write a project.toml with a qa agent so start_agent can resolve it. // Write a project.toml with a qa agent so start_agent can resolve it.
@@ -51,7 +51,7 @@ async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() {
"9919_story_no_commits", "9919_story_no_commits",
"2_current", "2_current",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -146,13 +146,13 @@ stage = "qa"
"292_story_first", "292_story_first",
"3_qa", "3_qa",
"---\nname: First\nqa: human\n---\n", "---\nname: First\nqa: human\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: First\nqa: human\n---\n"), crate::db::ItemMeta::named("First"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"293_story_second", "293_story_second",
"3_qa", "3_qa",
"---\nname: Second\nqa: human\n---\n", "---\nname: Second\nqa: human\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Second\nqa: human\n---\n"), crate::db::ItemMeta::named("Second"),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -252,7 +252,7 @@ async fn stale_mergemaster_advance_for_done_story_is_noop() {
story_id, story_id,
"5_done", "5_done",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::named("Zombie Merge Test"),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -389,7 +389,7 @@ async fn work_survived_advances_to_qa_instead_of_blocking() {
"9945_story_survived", "9945_story_survived",
"2_current", "2_current",
"---\nname: Survived Test\n---\n", "---\nname: Survived Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Survived Test\n---\n"), crate::db::ItemMeta::named("Survived Test"),
); );
// Simulate a passing run_tests call during the agent's session (bug 668): // Simulate a passing run_tests call during the agent's session (bug 668):
@@ -483,7 +483,7 @@ async fn no_committed_work_still_retries_and_blocks() {
"9946_story_nowork", "9946_story_nowork",
"2_current", "2_current",
"---\nname: No Work Test\n---\n", "---\nname: No Work Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: No Work Test\n---\n"), crate::db::ItemMeta::named("No Work Test"),
); );
// Write a project.toml with max_retries = 1. // Write a project.toml with max_retries = 1.
@@ -611,7 +611,7 @@ async fn gates_failed_no_test_evidence_does_not_advance() {
"9947_story_no_evidence", "9947_story_no_evidence",
"2_current", "2_current",
"---\nname: No Evidence Test\n---\n", "---\nname: No Evidence Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: No Evidence Test\n---\n"), crate::db::ItemMeta::named("No Evidence Test"),
); );
// Explicitly ensure no test evidence exists for this story. // Explicitly ensure no test evidence exists for this story.
@@ -741,7 +741,7 @@ async fn gates_failed_with_test_evidence_and_committed_work_advances() {
"9948_story_with_evidence", "9948_story_with_evidence",
"2_current", "2_current",
"---\nname: With Evidence Test\n---\n", "---\nname: With Evidence Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: With Evidence Test\n---\n"), crate::db::ItemMeta::named("With Evidence Test"),
); );
// Write the run_tests evidence — simulates the agent having called run_tests // Write the run_tests evidence — simulates the agent having called run_tests
@@ -825,7 +825,7 @@ stage = "coder"
"9950_story_warm_resume", "9950_story_warm_resume",
"2_current", "2_current",
"---\nname: Warm Resume Test\n---\n", "---\nname: Warm Resume Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Warm Resume Test\n---\n"), crate::db::ItemMeta::named("Warm Resume Test"),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
@@ -553,7 +553,7 @@ async fn zero_commit_coder_exit_stays_in_coding_not_promoted_to_merge() {
"9910_zero_exit", "9910_zero_exit",
"2_current", "2_current",
"---\nname: Zero Exit Test\n---\n", "---\nname: Zero Exit Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Zero Exit Test\n---\n"), crate::db::ItemMeta::named("Zero Exit Test"),
); );
fs::create_dir_all(project_root.join(".huskies")).unwrap(); fs::create_dir_all(project_root.join(".huskies")).unwrap();
@@ -651,7 +651,7 @@ async fn server_side_merge_happy_path_advances_to_done() {
"757a_happy", "757a_happy",
"4_merge", "4_merge",
"---\nname: Happy path test\n---\n", "---\nname: Happy path test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Happy path test\n---\n"), crate::db::ItemMeta::named("Happy path test"),
); );
let pool = Arc::new(AgentPool::new_test(3001)); let pool = Arc::new(AgentPool::new_test(3001));
@@ -788,7 +788,7 @@ async fn server_side_merge_conflict_sets_merge_failure() {
"757b_conflict", "757b_conflict",
"4_merge", "4_merge",
"---\nname: Conflict test\n---\n", "---\nname: Conflict test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Conflict test\n---\n"), crate::db::ItemMeta::named("Conflict test"),
); );
let pool = Arc::new(AgentPool::new_test(3001)); let pool = Arc::new(AgentPool::new_test(3001));
@@ -901,7 +901,7 @@ async fn server_side_merge_gate_failure_sets_merge_failure() {
"757c_gates", "757c_gates",
"4_merge", "4_merge",
"---\nname: Gate failure test\n---\n", "---\nname: Gate failure test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Gate failure test\n---\n"), crate::db::ItemMeta::named("Gate failure test"),
); );
let pool = Arc::new(AgentPool::new_test(3001)); let pool = Arc::new(AgentPool::new_test(3001));
+3 -3
View File
@@ -641,7 +641,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
crate::crdt_state::set_retry_count(story_id, 1); crate::crdt_state::set_retry_count(story_id, 1);
@@ -679,7 +679,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
// retry_count is 0 (default — never bumped). // retry_count is 0 (default — never bumped).
@@ -744,7 +744,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
let db_key = format!("{story_id}:abort_respawn_count"); let db_key = format!("{story_id}:abort_respawn_count");
@@ -433,7 +433,7 @@ async fn start_agent_rejects_mergemaster_on_coding_stage_story() {
"310_story_foo", "310_story_foo",
"2_current", "2_current",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), crate::db::ItemMeta::named("Foo"),
); );
let pool = AgentPool::new_test(3099); let pool = AgentPool::new_test(3099);
@@ -472,7 +472,7 @@ async fn start_agent_rejects_coder_on_qa_stage_story() {
"8842_story_qa_guard", "8842_story_qa_guard",
"3_qa", "3_qa",
"---\nname: QA Guard\n---\n", "---\nname: QA Guard\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: QA Guard\n---\n"), crate::db::ItemMeta::named("QA Guard"),
); );
let pool = AgentPool::new_test(3099); let pool = AgentPool::new_test(3099);
@@ -511,7 +511,7 @@ async fn start_agent_rejects_qa_on_merge_stage_story() {
"55_story_baz", "55_story_baz",
"4_merge", "4_merge",
"---\nname: Baz\n---\n", "---\nname: Baz\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Baz\n---\n"), crate::db::ItemMeta::named("Baz"),
); );
let pool = AgentPool::new_test(3099); let pool = AgentPool::new_test(3099);
+1 -1
View File
@@ -147,7 +147,7 @@ mod tests {
"60_story_cleanup", "60_story_cleanup",
"2_current", "2_current",
story_content, story_content,
crate::db::ItemMeta::from_yaml(story_content), crate::db::ItemMeta::default(),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
+3 -3
View File
@@ -46,7 +46,7 @@ mod tests {
"10_story_test", "10_story_test",
"2_current", "2_current",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
assert!(matches!( assert!(matches!(
@@ -62,7 +62,7 @@ mod tests {
"11_story_test", "11_story_test",
"3_qa", "3_qa",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
assert!(matches!( assert!(matches!(
@@ -78,7 +78,7 @@ mod tests {
"12_story_test", "12_story_test",
"4_merge", "4_merge",
"---\nname: Test\n---\n", "---\nname: Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), crate::db::ItemMeta::named("Test"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
assert!(matches!( assert!(matches!(
+6
View File
@@ -166,6 +166,7 @@ mod tests {
"1_backlog", "1_backlog",
"9912_story_foo.md", "9912_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
None,
); );
let output = depends_cmd_with_root(tmp.path(), "9912 abc").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9912 abc").unwrap();
assert!( assert!(
@@ -182,6 +183,7 @@ mod tests {
"1_backlog", "1_backlog",
"9910_story_foo.md", "9910_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
None,
); );
let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap();
assert!( assert!(
@@ -212,6 +214,7 @@ mod tests {
"2_current", "2_current",
"9911_story_bar.md", "9911_story_bar.md",
"---\nname: Bar\n---\n", "---\nname: Bar\n---\n",
None,
); );
// Pre-seed CRDT with deps so we can verify clearing. // Pre-seed CRDT with deps so we can verify clearing.
crate::crdt_state::set_depends_on("9911_story_bar", &[477]); crate::crdt_state::set_depends_on("9911_story_bar", &[477]);
@@ -248,6 +251,7 @@ mod tests {
"1_backlog", "1_backlog",
"8790_story_chat_dep.md", "8790_story_chat_dep.md",
"---\nname: Chat Dep\n---\n", "---\nname: Chat Dep\n---\n",
None,
); );
let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap(); let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap();
@@ -282,6 +286,7 @@ mod tests {
"1_backlog", "1_backlog",
"9920_story_scr.md", "9920_story_scr.md",
"---\nname: SCR\n---\n", "---\nname: SCR\n---\n",
None,
); );
// Set to [1, 2, 3]. // Set to [1, 2, 3].
@@ -322,6 +327,7 @@ mod tests {
"3_qa", "3_qa",
"9913_story_inqa.md", "9913_story_inqa.md",
"---\nname: In QA\n---\n", "---\nname: In QA\n---\n",
None,
); );
let output = depends_cmd_with_root(tmp.path(), "9913 100").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9913 100").unwrap();
assert!( assert!(
+1
View File
@@ -212,6 +212,7 @@ mod tests {
"2_current", "2_current",
"55551_story_no_worktree.md", "55551_story_no_worktree.md",
"---\nname: No Worktree\n---\n", "---\nname: No Worktree\n---\n",
None,
); );
let output = diff_cmd(tmp.path(), "55551").unwrap(); let output = diff_cmd(tmp.path(), "55551").unwrap();
assert!( assert!(
+6 -2
View File
@@ -193,7 +193,8 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"9940_story_freezeme.md", "9940_story_freezeme.md",
"---\nname: Freeze Me\n---\n# Story\n", "# Story\n",
Some("Freeze Me"),
); );
let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap(); let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap();
assert!( assert!(
@@ -219,7 +220,8 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"9941_story_frozen.md", "9941_story_frozen.md",
"---\nname: Frozen Story\n---\n# Story\n", "# Story\n",
Some("Frozen Story"),
); );
// Freeze first. // Freeze first.
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap(); let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
@@ -253,6 +255,7 @@ mod tests {
"2_current", "2_current",
"9942_story_notfrozen.md", "9942_story_notfrozen.md",
"---\nname: Not Frozen\n---\n# Story\n", "---\nname: Not Frozen\n---\n# Story\n",
None,
); );
let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap(); let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap();
assert!( assert!(
@@ -271,6 +274,7 @@ mod tests {
"2_current", "2_current",
"9943_story_alreadyfrozen.md", "9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\n---\n# Story\n", "---\nname: Already Frozen\n---\n# Story\n",
None,
); );
// Freeze it first. // Freeze it first.
freeze_cmd_with_root(tmp.path(), "9943").unwrap(); freeze_cmd_with_root(tmp.path(), "9943").unwrap();
+2
View File
@@ -202,6 +202,7 @@ mod tests {
"2_current", "2_current",
"77_story_no_log.md", "77_story_no_log.md",
"---\nname: No Log\n---\n", "---\nname: No Log\n---\n",
None,
); );
let output = logs_cmd(tmp.path(), "77").unwrap(); let output = logs_cmd(tmp.path(), "77").unwrap();
assert!( assert!(
@@ -221,6 +222,7 @@ mod tests {
"2_current", "2_current",
"88_story_has_log.md", "88_story_has_log.md",
"---\nname: Has Log\n---\n", "---\nname: Has Log\n---\n",
None,
); );
// Write a log file in the expected location. // Write a log file in the expected location.
let log_dir = tmp let log_dir = tmp
+5 -2
View File
@@ -170,7 +170,8 @@ mod tests {
tmp.path(), tmp.path(),
"1_backlog", "1_backlog",
"42_story_some_feature.md", "42_story_some_feature.md",
"---\nname: Some Feature\n---\n\n# Story 42\n", "# Story 42\n",
Some("Some Feature"),
); );
let output = move_cmd_with_root(tmp.path(), "42 current").unwrap(); let output = move_cmd_with_root(tmp.path(), "42 current").unwrap();
@@ -201,7 +202,8 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"8810_story_case_test.md", "8810_story_case_test.md",
"---\nname: CaseTest\n---\n", "",
Some("CaseTest"),
); );
let output = move_cmd_with_root(tmp.path(), "8810 BACKLOG").unwrap(); let output = move_cmd_with_root(tmp.path(), "8810 BACKLOG").unwrap();
assert!( assert!(
@@ -218,6 +220,7 @@ mod tests {
"2_current", "2_current",
"5_story_already_current.md", "5_story_already_current.md",
"---\nname: Already Current\n---\n", "---\nname: Already Current\n---\n",
None,
); );
// Moving to the stage it's already in should return a success message. // Moving to the stage it's already in should return a success message.
let output = move_cmd_with_root(tmp.path(), "5 current").unwrap(); let output = move_cmd_with_root(tmp.path(), "5 current").unwrap();
+3
View File
@@ -209,6 +209,7 @@ mod tests {
"1_backlog", "1_backlog",
"305_story_show_command.md", "305_story_show_command.md",
"---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.", "---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.",
None,
); );
let output = show_cmd_with_root(tmp.path(), "305").unwrap(); let output = show_cmd_with_root(tmp.path(), "305").unwrap();
assert!( assert!(
@@ -225,6 +226,7 @@ mod tests {
"2_current", "2_current",
"42_story_do_something.md", "42_story_do_something.md",
"---\nname: Do something\n---\n\n# Story 42\n\nIn progress.", "---\nname: Do something\n---\n\n# Story 42\n\nIn progress.",
None,
); );
let output = show_cmd_with_root(tmp.path(), "42").unwrap(); let output = show_cmd_with_root(tmp.path(), "42").unwrap();
assert!( assert!(
@@ -241,6 +243,7 @@ mod tests {
"1_backlog", "1_backlog",
"7_bug_crash_on_login.md", "7_bug_crash_on_login.md",
"---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.", "---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.",
None,
); );
let output = show_cmd_with_root(tmp.path(), "7").unwrap(); let output = show_cmd_with_root(tmp.path(), "7").unwrap();
assert!( assert!(
+2 -4
View File
@@ -630,10 +630,8 @@ fn merge_item_det_merge_running_preferred_over_failure() {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"906_story_det_over_fail", "906_story_det_over_fail",
"4_merge", "4_merge",
"---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n", "---\nname: Det Over Fail\n---\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Det Over Fail"),
"---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n",
),
); );
// Record a running deterministic merge in the CRDT. // Record a running deterministic merge in the CRDT.
crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None); crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None);
+15 -5
View File
@@ -307,7 +307,8 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"99_story_my_feature.md", "99_story_my_feature.md",
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n", "## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
Some("My Feature"),
); );
let output = status_triage_cmd(tmp.path(), "99").unwrap(); let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!(output.contains("99"), "should show story number: {output}"); assert!(output.contains("99"), "should show story number: {output}");
@@ -328,7 +329,8 @@ mod tests {
tmp.path(), tmp.path(),
"1_backlog", "1_backlog",
"9901_story_backlog_item.md", "9901_story_backlog_item.md",
"---\nname: Backlog Item\n---\n", "",
Some("Backlog Item"),
); );
let output = status_triage_cmd(tmp.path(), "9901").unwrap(); let output = status_triage_cmd(tmp.path(), "9901").unwrap();
assert!( assert!(
@@ -352,7 +354,8 @@ mod tests {
tmp.path(), tmp.path(),
"3_qa", "3_qa",
"9902_story_qa_item.md", "9902_story_qa_item.md",
"---\nname: QA Item\n---\n", "",
Some("QA Item"),
); );
let output = status_triage_cmd(tmp.path(), "9902").unwrap(); let output = status_triage_cmd(tmp.path(), "9902").unwrap();
assert!( assert!(
@@ -373,6 +376,7 @@ mod tests {
"2_current", "2_current",
"99_story_criteria_test.md", "99_story_criteria_test.md",
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n", "---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
None,
); );
let output = status_triage_cmd(tmp.path(), "99").unwrap(); let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!( assert!(
@@ -398,6 +402,7 @@ mod tests {
"2_current", "2_current",
"55_story_blocked_story.md", "55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n", "---\nname: Blocked Story\nblocked: true\n---\n",
None,
); );
let output = status_triage_cmd(tmp.path(), "55").unwrap(); let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!( assert!(
@@ -413,8 +418,10 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"55_story_agent_story.md", "55_story_agent_story.md",
"---\nname: Agent Story\nagent: coder-1\n---\n", "",
Some("Agent Story"),
); );
crate::crdt_state::set_agent("55_story_agent_story", Some("coder-1"));
let output = status_triage_cmd(tmp.path(), "55").unwrap(); let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!( assert!(
output.contains("coder-1"), output.contains("coder-1"),
@@ -429,8 +436,10 @@ mod tests {
tmp.path(), tmp.path(),
"2_current", "2_current",
"55_story_depends_story.md", "55_story_depends_story.md",
"---\nname: Depends Story\ndepends_on: [477, 478]\n---\n", "",
Some("Depends Story"),
); );
crate::crdt_state::set_depends_on("55_story_depends_story", &[477, 478]);
let output = status_triage_cmd(tmp.path(), "55").unwrap(); let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!( assert!(
output.contains("depends_on") || output.contains("#477"), output.contains("depends_on") || output.contains("#477"),
@@ -450,6 +459,7 @@ mod tests {
"2_current", "2_current",
"77_story_no_worktree.md", "77_story_no_worktree.md",
"---\nname: No Worktree\n---\n", "---\nname: No Worktree\n---\n",
None,
); );
let output = status_triage_cmd(tmp.path(), "77").unwrap(); let output = status_triage_cmd(tmp.path(), "77").unwrap();
// Branch name should still appear // Branch name should still appear
+8 -4
View File
@@ -181,6 +181,7 @@ mod tests {
"2_current", "2_current",
"42_story_test.md", "42_story_test.md",
"---\nname: Test Story\nretry_count: 2\n---\n# Story\n", "---\nname: Test Story\nretry_count: 2\n---\n# Story\n",
None,
); );
let output = unblock_cmd_with_root(tmp.path(), "42").unwrap(); let output = unblock_cmd_with_root(tmp.path(), "42").unwrap();
assert!( assert!(
@@ -200,6 +201,7 @@ mod tests {
"2_blocked", "2_blocked",
"9903_story_stuck.md", "9903_story_stuck.md",
"---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n", "---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
None,
); );
// Seed the story in the CRDT in 2_blocked stage so the typed // Seed the story in the CRDT in 2_blocked stage so the typed
// Blocked → Coding transition fires and clears `blocked` properly. // Blocked → Coding transition fires and clears `blocked` properly.
@@ -267,7 +269,7 @@ mod tests {
story_id, story_id,
stage, stage,
body, body,
crate::db::ItemMeta::from_yaml(body), crate::db::ItemMeta::named("Stuck Story"),
); );
// Seed CRDT registers: blocked=true, retry_count=5, with a name so the // Seed CRDT registers: blocked=true, retry_count=5, with a name so the
// response can echo it back instead of falling through to the raw id. // response can echo it back instead of falling through to the raw id.
@@ -312,14 +314,15 @@ mod tests {
#[test] #[test]
fn unblock_command_finds_story_in_any_stage() { fn unblock_command_finds_story_in_any_stage() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
// Use a high story number (9901) to avoid collisions with other tests in the
// global content store.
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"3_qa", "3_qa",
"9901_story_in_qa.md", "9901_story_in_qa.md",
"---\nname: In QA\nblocked: true\nretry_count: 3\n---\n# Story\n", "# Story\n",
Some("In QA"),
); );
crate::crdt_state::set_blocked("9901_story_in_qa", true);
crate::crdt_state::set_retry_count("9901_story_in_qa", 3);
let output = unblock_cmd_with_root(tmp.path(), "9901").unwrap(); let output = unblock_cmd_with_root(tmp.path(), "9901").unwrap();
assert!( assert!(
@@ -338,6 +341,7 @@ mod tests {
"1_backlog", "1_backlog",
"9902_story_blocked_one.md", "9902_story_blocked_one.md",
"---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n", "---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
None,
); );
let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap(); let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap();
+4
View File
@@ -99,6 +99,7 @@ mod tests {
"1_backlog", "1_backlog",
"9970_story_some_feature.md", "9970_story_some_feature.md",
"---\nname: Some Feature\n---\n\n# Story 9970\n", "---\nname: Some Feature\n---\n\n# Story 9970\n",
None,
); );
let (story_id, _stage_dir, path, content) = let (story_id, _stage_dir, path, content) =
find_story_by_number(tmp.path(), "9970").expect("should find story 9970"); find_story_by_number(tmp.path(), "9970").expect("should find story 9970");
@@ -121,6 +122,7 @@ mod tests {
"2_current", "2_current",
"7_bug_crash_on_login.md", "7_bug_crash_on_login.md",
"---\nname: Crash on login\n---\n", "---\nname: Crash on login\n---\n",
None,
); );
let (story_id, _stage_dir, _, _) = let (story_id, _stage_dir, _, _) =
find_story_by_number(tmp.path(), "7").expect("should find bug 7"); find_story_by_number(tmp.path(), "7").expect("should find bug 7");
@@ -136,6 +138,7 @@ mod tests {
"1_backlog", "1_backlog",
"9971_story_foo.md", "9971_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
None,
); );
let result = find_story_by_number(tmp.path(), "99710"); let result = find_story_by_number(tmp.path(), "99710");
assert!(result.is_none(), "number 99710 should not match story 9971"); assert!(result.is_none(), "number 99710 should not match story 9971");
@@ -149,6 +152,7 @@ mod tests {
"4_merge", "4_merge",
"503_story_migration.md", "503_story_migration.md",
"---\nname: Migration\n---\n", "---\nname: Migration\n---\n",
None,
); );
let (_, _, path, _) = let (_, _, path, _) =
find_story_by_number(tmp.path(), "503").expect("should find story 503"); find_story_by_number(tmp.path(), "503").expect("should find story 503");
+15 -4
View File
@@ -10,15 +10,26 @@ use std::path::Path;
/// which still verify filesystem state (e.g. assign tests that check the /// which still verify filesystem state (e.g. assign tests that check the
/// physical file) continue to work. /// physical file) continue to work.
/// ///
/// Uses `write_item_with_content` to populate both the in-memory content /// Story 929: callers pass the typed `name` explicitly — `content` is now
/// store and the CRDT, matching the production write path. /// just the markdown body and is no longer parsed for metadata. Other
pub(crate) fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) { /// typed fields (depends_on, agent, qa_mode, blocked, …) should be set
/// via the CRDT typed setters after this call.
pub(crate) fn write_story_file(
root: &Path,
stage: &str,
filename: &str,
content: &str,
name: Option<&str>,
) {
let dir = root.join(".huskies/work").join(stage); let dir = root.join(".huskies/work").join(stage);
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap(); std::fs::write(dir.join(filename), content).unwrap();
let story_id = filename.trim_end_matches(".md"); let story_id = filename.trim_end_matches(".md");
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let meta = crate::db::ItemMeta::from_yaml(content); let meta = crate::db::ItemMeta {
name: name.map(str::to_string),
..Default::default()
};
crate::db::write_item_with_content(story_id, stage, content, meta); crate::db::write_item_with_content(story_id, stage, content, meta);
} }
@@ -310,6 +310,7 @@ mod tests {
"1_backlog", "1_backlog",
"9972_story_test.md", "9972_story_test.md",
"---\nname: Test Feature\n---\n\n# Story 9972\n", "---\nname: Test Feature\n---\n\n# Story 9972\n",
None,
); );
// Seed CRDT so set_agent can write to the item. // Seed CRDT so set_agent can write to the item.
crate::crdt_state::write_item( crate::crdt_state::write_item(
@@ -366,6 +367,7 @@ mod tests {
"1_backlog", "1_backlog",
"9973_story_small.md", "9973_story_small.md",
"---\nname: Small Story\n---\n", "---\nname: Small Story\n---\n",
None,
); );
crate::crdt_state::write_item( crate::crdt_state::write_item(
"9973_story_small", "9973_story_small",
@@ -416,6 +418,7 @@ mod tests {
"1_backlog", "1_backlog",
"9974_story_existing.md", "9974_story_existing.md",
"---\nname: Existing\nagent: coder-sonnet\n---\n", "---\nname: Existing\nagent: coder-sonnet\n---\n",
None,
); );
crate::crdt_state::write_item( crate::crdt_state::write_item(
"9974_story_existing", "9974_story_existing",
@@ -456,6 +459,7 @@ mod tests {
"3_qa", "3_qa",
"99_story_in_qa.md", "99_story_in_qa.md",
"---\nname: In QA\n---\n", "---\nname: In QA\n---\n",
None,
); );
let agents = std::sync::Arc::new(AgentPool::new_test(3000)); let agents = std::sync::Arc::new(AgentPool::new_test(3000));
@@ -476,6 +480,7 @@ mod tests {
"2_current", "2_current",
"10_story_current.md", "10_story_current.md",
"---\nname: Current Story\nagent: coder-sonnet\n---\n", "---\nname: Current Story\nagent: coder-sonnet\n---\n",
None,
); );
let agents = std::sync::Arc::new(AgentPool::new_test(3000)); let agents = std::sync::Arc::new(AgentPool::new_test(3000));
+2 -4
View File
@@ -258,9 +258,7 @@ mod tests {
story_id, story_id,
"1_backlog", "1_backlog",
"---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n", "---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("CRDT Tombstone Check"),
"---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n",
),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -291,7 +289,7 @@ mod tests {
"9975_story_some_feature", "9975_story_some_feature",
"1_backlog", "1_backlog",
"---\nname: Some Feature\n---\n\n# Story 9975\n", "---\nname: Some Feature\n---\n\n# Story 9975\n",
crate::db::ItemMeta::from_yaml("---\nname: Some Feature\n---\n\n# Story 9975\n"), crate::db::ItemMeta::named("Some Feature"),
); );
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
+1 -1
View File
@@ -272,7 +272,7 @@ mod tests {
"9976_story_test", "9976_story_test",
"1_backlog", "1_backlog",
"---\nname: Test Story\n---\n", "---\nname: Test Story\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Test Story\n---\n"), crate::db::ItemMeta::named("Test Story"),
); );
let agents = Arc::new(AgentPool::new_test(3000)); let agents = Arc::new(AgentPool::new_test(3000));
+1 -1
View File
@@ -54,7 +54,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_blocked, set_depends_on, set_epic, set_item_type, set_mergemaster_attempted, set_agent, set_blocked, set_depends_on, set_epic, set_item_type, set_mergemaster_attempted,
set_qa_mode, set_retry_count, set_review_hold, write_item, set_name, set_qa_mode, set_retry_count, set_review_hold, write_item,
}; };
#[cfg(test)] #[cfg(test)]
+23
View File
@@ -128,6 +128,29 @@ pub fn set_mergemaster_attempted(story_id: &str, value: bool) -> bool {
true true
} }
/// Set the `name` field for a pipeline item by its story ID.
///
/// `Some(name)` writes the human-readable name into the CRDT register.
/// `None` clears the register by writing an empty string.
///
/// Returns `true` if the item was found and the write was performed.
pub fn set_name(story_id: &str, name: Option<&str>) -> 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 = name.unwrap_or("").to_string();
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].name.set(value.clone())
});
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.
+1 -1
View File
@@ -11,6 +11,6 @@ mod tests;
pub use item::{ pub use item::{
bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_item_type, bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_item_type,
set_mergemaster_attempted, set_qa_mode, set_retry_count, set_review_hold, write_item, set_mergemaster_attempted, set_name, set_qa_mode, set_retry_count, set_review_hold, write_item,
}; };
pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id}; pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id};
+5 -23
View File
@@ -19,9 +19,6 @@ pub mod content_store;
pub mod ops; pub mod ops;
/// Background shadow-write task — persists pipeline items to SQLite asynchronously. /// Background shadow-write task — persists pipeline items to SQLite asynchronously.
pub mod shadow_write; pub mod shadow_write;
/// Legacy YAML helpers — used by callers reading the small set of fields not
/// yet mirrored into the CRDT.
pub(crate) mod yaml_legacy;
pub use content_store::{all_content_ids, delete_content, read_content, write_content}; pub use content_store::{all_content_ids, delete_content, read_content, write_content};
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content}; pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
@@ -33,7 +30,6 @@ pub use content_store::ensure_content_store;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs; use std::fs;
/// Helper: write a minimal story .md file with front matter. /// Helper: write a minimal story .md file with front matter.
@@ -104,25 +100,11 @@ mod tests {
assert_eq!(row.0, "10_story_shadow_test"); assert_eq!(row.0, "10_story_shadow_test");
assert_eq!(row.1.as_deref(), Some("Shadow Test")); assert_eq!(row.1.as_deref(), Some("Shadow Test"));
assert_eq!(row.2, "2_current"); assert_eq!(row.2, "2_current");
// The shadow row's name + retry_count came through from the INSERT
// Verify metadata was parsed correctly from the story file. // params above; that's what the test exercises. Story 929 dropped
let (name, _agent, retry_count, _blocked, _depends_on) = // the redundant "re-parse the YAML body to double-check" step that
match std::fs::read_to_string(&story_path) { // used to live here.
Ok(contents) => match parse_front_matter(&contents) { let _ = story_path;
Ok(meta) => (
meta.name,
meta.agent,
meta.retry_count.map(|r| r as i64),
meta.blocked,
meta.depends_on,
),
Err(_) => (None, None, None, None, None),
},
Err(_) => (None, None, None, None, None),
};
assert_eq!(name.as_deref(), Some("Shadow Test"));
assert_eq!(retry_count, Some(2));
} }
#[tokio::test] #[tokio::test]
+3 -25
View File
@@ -7,14 +7,12 @@ use super::content_store::{
all_content_ids, delete_content, ensure_content_store, read_content, write_content, all_content_ids, delete_content, ensure_content_store, read_content, write_content,
}; };
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg}; use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
use super::yaml_legacy::parse_front_matter;
/// Typed metadata for a pipeline item write. /// Typed metadata for a pipeline item write.
/// ///
/// Replaces the prior YAML-parsing write path (story 864): callers now pass /// Story 929: callers pass metadata explicitly — no YAML parsing. Every
/// metadata explicitly instead of round-tripping it through a serialized /// field is `Option`-typed; `None` means "leave unchanged" on update,
/// front-matter blob. Every field is `Option`-typed; `None` means /// "use the default" on insert.
/// "leave unchanged" on update, "use the default" on insert.
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct ItemMeta { pub struct ItemMeta {
pub name: Option<String>, pub name: Option<String>,
@@ -33,26 +31,6 @@ impl ItemMeta {
..Self::default() ..Self::default()
} }
} }
/// Parse YAML front-matter from a content string into typed metadata.
///
/// This is an explicit caller-side conversion — the write path itself
/// no longer parses YAML. Use this when the caller has a raw content
/// string with front-matter and wants the metadata to flow into the
/// CRDT. Returns `Self::default()` if parsing fails or there is no
/// front-matter present.
pub fn from_yaml(content: &str) -> Self {
match parse_front_matter(content) {
Ok(m) => Self {
name: m.name,
agent: m.agent,
retry_count: m.retry_count.map(|r| r as i64),
blocked: m.blocked,
depends_on: m.depends_on,
},
Err(_) => Self::default(),
}
}
} }
/// Write a pipeline item from in-memory content (no filesystem access). /// Write a pipeline item from in-memory content (no filesystem access).
-166
View File
@@ -1,166 +0,0 @@
//! Legacy YAML front-matter helpers — kept ONLY for the one-shot migration
//! and for the small set of fields not yet mirrored into the CRDT
//! (`item_type`, `epic`, `review_hold`, `merge_failure` reason text, etc.).
//!
//! After the migration runs, every body in the content store is YAML-free, so
//! every helper here returns `Ok(None)` / a no-op on the next read. Callers
//! should treat this module as a deprecated escape hatch — new code should
//! read typed CRDT registers instead.
use crate::io::story_metadata::QaMode;
use serde::Deserialize;
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
/// the original `io::story_metadata::FrontMatter`.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct FrontMatter {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<String>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
#[serde(rename = "type")]
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Parsed metadata view returned by [`parse_front_matter`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<QaMode>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Errors that can occur when parsing legacy YAML front matter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
impl std::fmt::Display for StoryMetaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingFrontMatter => write!(f, "Missing front matter"),
Self::InvalidFrontMatter(m) => write!(f, "Invalid front matter: {m}"),
}
}
}
/// Parse the YAML front-matter block from a markdown body.
///
/// Post-migration this returns `Err(StoryMetaError::MissingFrontMatter)` for
/// every body since the front matter has been stripped. Callers that need
/// fields not stored in the CRDT (`item_type`, `epic`, …) should treat the
/// missing-front-matter case as "default value".
pub(crate) fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
if line.trim() == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(StoryMetadata {
qa: front.qa.as_deref().and_then(QaMode::from_str),
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
retry_count: front.retry_count,
blocked: front.blocked,
depends_on: front.depends_on,
frozen: front.frozen,
resume_to_stage: front.resume_to_stage,
run_tests_passed: front.run_tests_passed,
item_type: front.item_type,
mergemaster_attempted: front.mergemaster_attempted,
epic: front.epic,
});
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
/// Insert or update a `key: value` line in the YAML front matter of a
/// markdown string. Returns the input unchanged if no `---` block is found.
pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
let existing_idx = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1);
let new_line = format!("{key}: {value}");
if let Some(idx) = existing_idx {
lines[idx] = new_line;
} else {
lines.insert(close_idx, new_line);
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_front_matter_round_trips_basic_fields() {
let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n";
let meta = parse_front_matter(input).expect("parse");
assert_eq!(meta.name.as_deref(), Some("Test"));
assert_eq!(meta.agent.as_deref(), Some("coder-1"));
}
#[test]
fn parse_front_matter_returns_missing_when_no_yaml() {
let err = parse_front_matter("# Plain markdown\n").unwrap_err();
assert_eq!(err, StoryMetaError::MissingFrontMatter);
}
#[test]
fn set_front_matter_field_inserts_when_absent() {
let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1");
assert!(out.contains("agent: coder-1"));
}
}
+2 -2
View File
@@ -353,7 +353,7 @@ async fn get_work_item_content_falls_back_to_crdt_when_no_file() {
"44_story_crdt_only", "44_story_crdt_only",
"1_backlog", "1_backlog",
"---\nname: \"CRDT Only\"\n---\n\nCRDT content.", "---\nname: \"CRDT Only\"\n---\n\nCRDT content.",
crate::db::ItemMeta::from_yaml("---\nname: \"CRDT Only\"\n---\n\nCRDT content."), crate::db::ItemMeta::named("CRDT Only"),
); );
let ctx = AppContext::new_test(root); let ctx = AppContext::new_test(root);
let api = AgentsApi { ctx: Arc::new(ctx) }; let api = AgentsApi { ctx: Arc::new(ctx) };
@@ -376,7 +376,7 @@ async fn get_work_item_content_crdt_fallback_with_current_stage() {
"45_story_crdt_current", "45_story_crdt_current",
"2_current", "2_current",
"---\nname: \"Current CRDT\"\n---\n\nIn progress.", "---\nname: \"Current CRDT\"\n---\n\nIn progress.",
crate::db::ItemMeta::from_yaml("---\nname: \"Current CRDT\"\n---\n\nIn progress."), crate::db::ItemMeta::named("Current CRDT"),
); );
let ctx = AppContext::new_test(root); let ctx = AppContext::new_test(root);
let api = AgentsApi { ctx: Arc::new(ctx) }; let api = AgentsApi { ctx: Arc::new(ctx) };
+4 -47
View File
@@ -3,7 +3,6 @@
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::http::context::AppContext; use crate::http::context::AppContext;
use crate::slog_warn;
use super::worktree::get_worktree_commits; use super::worktree::get_worktree_commits;
@@ -32,16 +31,10 @@ pub(crate) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result<S
.await? .await?
}; };
// Snapshot coverage baseline from the most recent coverage report (best-effort). // Story 929: the legacy "snapshot coverage baseline into story YAML"
if let Some(pct) = read_coverage_percent_from_json(&project_root) // hook is gone — the field was write-only (nothing read it back) and
&& let Err(e) = crate::http::workflow::write_coverage_baseline_to_story_file( // the per-run report at `.huskies/coverage/server.json` is the
&project_root, // authoritative source for downstream tools.
story_id,
pct,
)
{
slog_warn!("[start_agent] Could not write coverage baseline to story file: {e}");
}
serde_json::to_string_pretty(&json!({ serde_json::to_string_pretty(&json!({
"story_id": info.story_id, "story_id": info.story_id,
@@ -53,21 +46,6 @@ pub(crate) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result<S
.map_err(|e| format!("Serialization error: {e}")) .map_err(|e| format!("Serialization error: {e}"))
} }
/// Try to read the overall line coverage percentage from the llvm-cov JSON report.
///
/// Expects the file at `{project_root}/.huskies/coverage/server.json`.
pub(crate) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option<f64> {
let path = project_root
.join(".huskies")
.join("coverage")
.join("server.json");
let contents = std::fs::read_to_string(&path).ok()?;
let json: Value = serde_json::from_str(&contents).ok()?;
// cargo llvm-cov --json format: data[0].totals.lines.percent
json.pointer("/data/0/totals/lines/percent")
.and_then(|v| v.as_f64())
}
pub(crate) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<String, String> { pub(crate) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
@@ -334,25 +312,4 @@ stage = "coder"
// completion key present (null for agents that didn't call report_completion) // completion key present (null for agents that didn't call report_completion)
assert!(parsed.get("completion").is_some()); assert!(parsed.get("completion").is_some());
} }
#[test]
fn read_coverage_percent_from_json_parses_llvm_cov_format() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let cov_dir = tmp.path().join(".huskies/coverage");
fs::create_dir_all(&cov_dir).unwrap();
let json_content =
r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#;
fs::write(cov_dir.join("server.json"), json_content).unwrap();
let pct = read_coverage_percent_from_json(tmp.path());
assert_eq!(pct, Some(78.0));
}
#[test]
fn read_coverage_percent_from_json_returns_none_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let pct = read_coverage_percent_from_json(tmp.path());
assert!(pct.is_none());
}
} }
@@ -453,7 +453,7 @@ mod tests {
"5_story_test", "5_story_test",
"1_backlog", "1_backlog",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
@@ -485,7 +485,7 @@ mod tests {
"6_story_back", "6_story_back",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
@@ -517,7 +517,7 @@ mod tests {
"9907_story_idem", "9907_story_idem",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
+8 -4
View File
@@ -362,13 +362,16 @@ mod tests {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let story_content = "---\nname: Blocked Story\nblocked: true\nretry_count: 3\ndepends_on: [100, 200]\n---\n\n## Acceptance Criteria\n\n- [ ] Do the thing\n"; let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Do the thing\n";
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9887_story_blocked_test", "9887_story_blocked_test",
"2_current", "2_current",
story_content, story_content,
crate::db::ItemMeta::from_yaml(story_content), crate::db::ItemMeta::named("Blocked Story"),
); );
crate::crdt_state::set_blocked("9887_story_blocked_test", true);
crate::crdt_state::set_retry_count("9887_story_blocked_test", 3);
crate::crdt_state::set_depends_on("9887_story_blocked_test", &[100, 200]);
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "9887_story_blocked_test"}), &ctx) let result = tool_status(&json!({"story_id": "9887_story_blocked_test"}), &ctx)
@@ -389,13 +392,14 @@ mod tests {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n"; let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9886_story_status_test", "9886_story_status_test",
"2_current", "2_current",
story_content, story_content,
crate::db::ItemMeta::from_yaml(story_content), crate::db::ItemMeta::named("My Test Story"),
); );
crate::crdt_state::set_agent("9886_story_status_test", Some("coder-1"));
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "9886_story_status_test"}), &ctx) let result = tool_status(&json!({"story_id": "9886_story_status_test"}), &ctx)
+5 -9
View File
@@ -287,18 +287,14 @@ mod tests {
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9902_bug_crash", "9902_bug_crash",
"1_backlog", "1_backlog",
"---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", "# Bug 9902: App Crash\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("App Crash"),
"---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n",
),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9903_bug_typo", "9903_bug_typo",
"1_backlog", "1_backlog",
"---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", "# Bug 9903: Typo in Header\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Typo in Header"),
"---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -446,7 +442,7 @@ mod tests {
"9901_bug_crash", "9901_bug_crash",
"1_backlog", "1_backlog",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::default(),
); );
// Stage the file so it's tracked // Stage the file so it's tracked
std::process::Command::new("git") std::process::Command::new("git")
+7 -17
View File
@@ -421,9 +421,7 @@ mod tests {
"9901_test", "9901_test",
"2_current", "2_current",
"---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", "---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Test"),
"---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -517,7 +515,7 @@ mod tests {
"9906_story_persist", "9906_story_persist",
"2_current", "2_current",
"---\nname: Persist\n---\n# Story\n", "---\nname: Persist\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Persist\n---\n# Story\n"), crate::db::ItemMeta::named("Persist"),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -555,7 +553,7 @@ mod tests {
"9905_story_file_only", "9905_story_file_only",
"2_current", "2_current",
story_content, story_content,
crate::db::ItemMeta::from_yaml(story_content), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -627,9 +625,7 @@ mod tests {
"9997_empty_branch", "9997_empty_branch",
"2_current", "2_current",
"---\nname: Empty Branch Test\n---\n## AC\n- [ ] Implement the feature\n", "---\nname: Empty Branch Test\n---\n## AC\n- [ ] Implement the feature\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Empty Branch Test"),
"---\nname: Empty Branch Test\n---\n## AC\n- [ ] Implement the feature\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -690,9 +686,7 @@ mod tests {
"9904_test", "9904_test",
"2_current", "2_current",
"---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Test"),
"---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -744,9 +738,7 @@ mod tests {
"9905_test", "9905_test",
"2_current", "2_current",
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n", "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Test"),
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -768,9 +760,7 @@ mod tests {
"9906_test", "9906_test",
"2_current", "2_current",
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n", "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Test"),
"---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n",
),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -230,7 +230,7 @@ mod tests {
"51_story_no_branch", "51_story_no_branch",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -60,7 +60,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: MCP Freeze Tool Test\n---\n", "---\nname: MCP Freeze Tool Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: MCP Freeze Tool Test\n---\n"), crate::db::ItemMeta::named("MCP Freeze Tool Test"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -89,7 +89,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: MCP Unfreeze Tool Test\n---\n", "---\nname: MCP Unfreeze Tool Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: MCP Unfreeze Tool Test\n---\n"), crate::db::ItemMeta::named("MCP Unfreeze Tool Test"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -154,7 +154,10 @@ mod tests {
id, id,
stage, stage,
&format!("---\nname: \"{name}\"\n---\n"), &format!("---\nname: \"{name}\"\n---\n"),
crate::db::ItemMeta::from_yaml(&format!("---\nname: \"{name}\"\n---\n")), crate::db::ItemMeta {
name: Some(name.to_string()),
..Default::default()
},
); );
} }
@@ -192,7 +195,7 @@ mod tests {
"9921_story_active", "9921_story_active",
"2_current", "2_current",
"---\nname: \"Active Story\"\n---\n", "---\nname: \"Active Story\"\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: \"Active Story\"\n---\n"), crate::db::ItemMeta::named("Active Story"),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -225,7 +228,7 @@ mod tests {
"9907_test", "9907_test",
"2_current", "2_current",
"---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n", "---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n",
crate::db::ItemMeta::from_yaml("---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n"), crate::db::ItemMeta::named("Valid Story"),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -247,7 +250,7 @@ mod tests {
"9908_test", "9908_test",
"2_current", "2_current",
"## No front matter at all\n", "## No front matter at all\n",
crate::db::ItemMeta::from_yaml("## No front matter at all\n"), crate::db::ItemMeta::default(),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
+101 -280
View File
@@ -4,7 +4,6 @@ use crate::http::context::AppContext;
use crate::http::workflow::update_story_in_file; use crate::http::workflow::update_story_in_file;
use crate::slog_warn; use crate::slog_warn;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> { pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
@@ -14,88 +13,110 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
let user_story = args.get("user_story").and_then(|v| v.as_str()); let user_story = args.get("user_story").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str()); let description = args.get("description").and_then(|v| v.as_str());
// Collect front matter fields: explicit `name`/`agent` params + arbitrary `front_matter` object. // Explicit top-level args map onto typed CRDT registers directly (story 929:
// Values are passed as serde_json::Value so native booleans, numbers, and arrays are // no YAML front-matter writes). The `front_matter` object is the legacy
// preserved and encoded correctly as unquoted YAML by update_story_in_file. // escape hatch; every known key is recognised and routed below, and any
let mut front_matter: HashMap<String, Value> = HashMap::new(); // unknown key is rejected loudly rather than silently flushed to disk.
if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
front_matter.insert("name".to_string(), Value::String(name.to_string())); crate::crdt_state::set_name(story_id, Some(name));
} }
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) { if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
front_matter.insert("agent".to_string(), Value::String(agent.to_string())); crate::crdt_state::set_agent(story_id, Some(agent));
} }
if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) { if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) {
front_matter.insert("epic".to_string(), Value::String(epic.to_string())); crate::crdt_state::set_epic(story_id, Some(epic).filter(|s| !s.is_empty()));
} }
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) { if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
for (k, v) in obj { for (key, value) in obj {
front_matter.insert(k.clone(), v.clone()); match key.as_str() {
"acceptance_criteria" => {
return Err(
"'acceptance_criteria' is a reserved field managed via the story body \
(use add_criterion / remove_criterion / edit_criterion instead)."
.to_string(),
);
}
"name" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_name(story_id, s);
}
"agent" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_agent(story_id, s);
}
"qa" => {
let mode = value
.as_str()
.and_then(crate::io::story_metadata::QaMode::from_str);
crate::crdt_state::set_qa_mode(story_id, mode);
}
"epic" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_epic(story_id, s);
}
"type" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_item_type(story_id, s);
}
"depends_on" => {
if let Some(arr) = value.as_array() {
let 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, &nums);
} else if value.is_null() {
crate::crdt_state::set_depends_on(story_id, &[]);
}
}
"blocked" => {
if let Some(b) = value.as_bool() {
crate::crdt_state::set_blocked(story_id, b);
} else if value.as_str() == Some("true") {
crate::crdt_state::set_blocked(story_id, true);
} else if value.as_str() == Some("false") {
crate::crdt_state::set_blocked(story_id, false);
}
}
"review_hold" => {
if let Some(b) = value.as_bool() {
crate::crdt_state::set_review_hold(story_id, b);
} else if value.as_str() == Some("true") {
crate::crdt_state::set_review_hold(story_id, true);
} else if value.as_str() == Some("false") {
crate::crdt_state::set_review_hold(story_id, false);
}
}
"retry_count" => {
let n = value
.as_i64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()));
if let Some(n) = n {
crate::crdt_state::set_retry_count(story_id, n);
}
}
"mergemaster_attempted" => {
if let Some(b) = value.as_bool() {
crate::crdt_state::set_mergemaster_attempted(story_id, b);
}
}
other => {
return Err(format!(
"Unknown front_matter field '{other}'. Story 929 removed the generic \
YAML pass-through; supported keys: name, agent, qa, epic, type, \
depends_on, blocked, review_hold, retry_count, mergemaster_attempted."
));
}
}
} }
} }
// Intercept `qa` field — route through the typed CRDT register instead of YAML.
if let Some(qa_val) = front_matter.remove("qa") {
let mode = qa_val
.as_str()
.and_then(crate::io::story_metadata::QaMode::from_str);
crate::crdt_state::set_qa_mode(story_id, mode);
}
// Story 933: intercept `epic` and `type` fields — route to typed CRDT
// registers so the auto-assigner / epic-rollup tools see the change.
if let Some(epic_val) = front_matter.remove("epic") {
let epic_id = epic_val.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_epic(story_id, epic_id);
}
if let Some(type_val) = front_matter.remove("type") {
let item_type = type_val.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_item_type(story_id, item_type);
}
// Route `depends_on` to the typed CRDT register and remove it from the
// front-matter map so it is NOT written back to the YAML content store.
// This matches the `qa` field pattern: CRDT is the single source of truth.
if let Some(deps_val) = front_matter.remove("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() {
None
} else {
Some(&front_matter)
};
// Capture the agent value before moving front_matter into the file writer,
// so we can mirror it into the CRDT register below.
let agent_for_crdt = args
.get("agent")
.and_then(|v| v.as_str())
.or_else(|| {
args.get("front_matter")
.and_then(|v| v.as_object())
.and_then(|o| o.get("agent"))
.and_then(|v| v.as_str())
})
.map(str::to_string);
let root = ctx.state.get_project_root()?; let root = ctx.state.get_project_root()?;
// Only call update_story_in_file when there is something left to write. // Only call update_story_in_file when there is body content to update.
if user_story.is_some() || description.is_some() || front_matter_opt.is_some() { if user_story.is_some() || description.is_some() {
update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?; update_story_in_file(&root, story_id, user_story, description)?;
}
// Mirror the agent assignment into the CRDT register so the in-memory
// pipeline state stays consistent with the front-matter.
if let Some(ref a) = agent_for_crdt {
crate::crdt_state::set_agent(story_id, Some(a));
} }
// Bug 503: warn if any depends_on in the (now updated) story points at an archived story. // Bug 503: warn if any depends_on in the (now updated) story points at an archived story.
@@ -138,214 +159,14 @@ pub(crate) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result<Strin
format!("Invalid story_id format: '{story_id}'. Expected a numeric ID (e.g. '42').") format!("Invalid story_id format: '{story_id}'. Expected a numeric ID (e.g. '42').")
})?; })?;
Ok(crate::chat::commands::unblock::unblock_by_number( let result = crate::chat::commands::unblock::unblock_by_number(&root, story_number);
&root, if result.contains("not blocked")
story_number, || result.contains("not found")
)) || result.contains("Error")
} || result.contains("error")
{
#[cfg(test)] Err(result)
mod tests { } else {
use super::*; Ok(result)
use crate::http::test_helpers::test_ctx;
use serde_json::json;
use std::fs;
fn setup_story_for_update(dir: &std::path::Path, story_id: &str, content: &str) {
let current = dir.join(".huskies/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join(format!("{story_id}.md")), content).unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
story_id,
"2_current",
content,
crate::db::ItemMeta::from_yaml(content),
);
}
#[test]
fn tool_update_story_front_matter_json_bool_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_for_update(
tmp.path(),
"504_bool_test",
"---\nname: Bool Test\n---\n\nNo sections.\n",
);
let ctx = test_ctx(tmp.path());
let result = tool_update_story(
&json!({"story_id": "504_bool_test", "front_matter": {"blocked": false}}),
&ctx,
);
assert!(result.is_ok(), "Expected ok: {result:?}");
let content = crate::db::read_content("504_bool_test").unwrap();
assert!(
content.contains("blocked: false"),
"bool should be unquoted: {content}"
);
assert!(
!content.contains("blocked: \"false\""),
"bool must not be quoted: {content}"
);
}
#[test]
fn tool_update_story_front_matter_json_number_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_for_update(
tmp.path(),
"504_num_test",
"---\nname: Num Test\n---\n\nNo sections.\n",
);
let ctx = test_ctx(tmp.path());
let result = tool_update_story(
&json!({"story_id": "504_num_test", "front_matter": {"retry_count": 3}}),
&ctx,
);
assert!(result.is_ok(), "Expected ok: {result:?}");
let content = crate::db::read_content("504_num_test").unwrap();
assert!(
content.contains("retry_count: 3"),
"number should be unquoted: {content}"
);
assert!(
!content.contains("retry_count: \"3\""),
"number must not be quoted: {content}"
);
}
/// AC4 regression: set [1,2,3] → clear [] → replace [4,5] — CRDT reflects
/// each write, and replace never appends.
#[test]
fn tool_update_story_depends_on_set_clear_replace() {
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::init_for_test();
setup_story_for_update(
tmp.path(),
"888_deps_scr",
"---\nname: Deps SCR\n---\n\nNo sections.\n",
);
let ctx = test_ctx(tmp.path());
// Set to [1, 2, 3].
let r = tool_update_story(
&json!({"story_id": "888_deps_scr", "front_matter": {"depends_on": [1, 2, 3]}}),
&ctx,
);
assert!(r.is_ok(), "set [1,2,3] should succeed: {r:?}");
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
assert_eq!(
view.depends_on(),
&[1, 2, 3],
"CRDT should hold [1,2,3] after set"
);
// Clear to [].
let r = tool_update_story(
&json!({"story_id": "888_deps_scr", "front_matter": {"depends_on": []}}),
&ctx,
);
assert!(r.is_ok(), "clear [] should succeed: {r:?}");
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
assert!(
view.depends_on().is_empty(),
"CRDT should be empty after clearing to []"
);
// Replace with [4, 5] — must not append to previous [1,2,3].
let r = tool_update_story(
&json!({"story_id": "888_deps_scr", "front_matter": {"depends_on": [4, 5]}}),
&ctx,
);
assert!(r.is_ok(), "replace [4,5] should succeed: {r:?}");
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
assert_eq!(
view.depends_on(),
&[4, 5],
"CRDT should hold exactly [4,5] after replace (not [1,2,3,4,5])"
);
}
/// Regression: clearing depends_on must survive a subsequent update to another
/// field. Before the fix, write_story_content would restore the old YAML
/// depends_on value into the CRDT register, overwriting the clear.
#[test]
fn tool_update_story_clear_depends_on_survives_subsequent_update() {
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::init_for_test();
// Story created WITH depends_on in YAML so write_story_content would
// previously restore it.
setup_story_for_update(
tmp.path(),
"888_deps_persist",
"---\nname: Deps Persist\ndepends_on: [100, 200]\n---\n\nNo sections.\n",
);
let ctx = test_ctx(tmp.path());
// Seed CRDT with the YAML deps (simulates the initial write path).
crate::crdt_state::set_depends_on("888_deps_persist", &[100, 200]);
// Clear deps via update_story.
let r = tool_update_story(
&json!({"story_id": "888_deps_persist", "front_matter": {"depends_on": []}}),
&ctx,
);
assert!(r.is_ok(), "clear should succeed: {r:?}");
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
assert!(
view.depends_on().is_empty(),
"CRDT should be empty after clear"
);
// Now update a different field — this triggers write_story_content with
// the stale YAML (which still has depends_on: [100, 200]).
let r = tool_update_story(
&json!({"story_id": "888_deps_persist", "name": "Deps Persist Updated"}),
&ctx,
);
assert!(r.is_ok(), "subsequent name update should succeed: {r:?}");
// The CRDT must still be empty — the YAML value must not have been restored.
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
assert!(
view.depends_on().is_empty(),
"CRDT depends_on must remain empty after unrelated update (write_story_content must not restore YAML value)"
);
}
#[test]
fn tool_update_story_depends_on_routes_to_crdt_not_yaml() {
let tmp = tempfile::tempdir().unwrap();
setup_story_for_update(
tmp.path(),
"504_arr_test",
"---\nname: Array Test\n---\n\nNo sections.\n",
);
let ctx = test_ctx(tmp.path());
let result = tool_update_story(
&json!({"story_id": "504_arr_test", "front_matter": {"depends_on": [490, 491]}}),
&ctx,
);
assert!(result.is_ok(), "Expected ok: {result:?}");
// CRDT register must hold the deps.
let view = crate::crdt_state::read_item("504_arr_test").expect("CRDT must have the story");
assert_eq!(
view.depends_on(),
&[490, 491],
"CRDT register should hold [490, 491]: {view:?}"
);
// Content store YAML must NOT contain depends_on — CRDT is sole source of truth.
let content = crate::db::read_content("504_arr_test").unwrap();
assert!(
!content.contains("depends_on"),
"depends_on must not be written to YAML content store: {content}"
);
} }
} }
+1 -1
View File
@@ -60,7 +60,7 @@ 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, Some(name));
// Sync depends_on to the typed CRDT register. // Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[])); crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[]));
+1 -1
View File
@@ -70,7 +70,7 @@ pub fn create_epic_file(
} }
// Epics are stored in backlog (no pipeline advancement). // Epics are stored in backlog (no pipeline advancement).
write_story_content(root, &epic_id, "1_backlog", &content); write_story_content(root, &epic_id, "1_backlog", &content, Some(name));
// Story 933: typed CRDT register for item_type. // Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&epic_id, Some("epic")); crate::crdt_state::set_item_type(&epic_id, Some("epic"));
+1 -1
View File
@@ -56,7 +56,7 @@ pub fn create_refactor_file(
content.push_str("- TBD\n"); content.push_str("- TBD\n");
// 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, Some(name));
// Sync depends_on to the typed CRDT register. // Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[])); crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[]));
+1 -1
View File
@@ -61,7 +61,7 @@ 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, Some(name));
// Sync depends_on to the typed CRDT register. // Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[])); crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[]));
+19 -57
View File
@@ -3,7 +3,6 @@
use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files}; use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files};
use super::refactor::{create_refactor_file, list_refactor_files}; use super::refactor::{create_refactor_file, list_refactor_files};
use super::spike::create_spike_file; use super::spike::create_spike_file;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs; use std::fs;
fn setup_git_repo(root: &std::path::Path) { fn setup_git_repo(root: &std::path::Path) {
@@ -50,13 +49,13 @@ fn next_item_number_increments_from_existing_bugs() {
"1_bug_crash", "1_bug_crash",
"1_backlog", "1_backlog",
"---\nname: Crash\n---\n", "---\nname: Crash\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Crash\n---\n"), crate::db::ItemMeta::named("Crash"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"3_bug_another", "3_bug_another",
"1_backlog", "1_backlog",
"---\nname: Another\n---\n", "---\nname: Another\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Another\n---\n"), crate::db::ItemMeta::named("Another"),
); );
assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4); assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4);
} }
@@ -75,7 +74,7 @@ fn next_item_number_scans_archived_too() {
"5_bug_old", "5_bug_old",
"5_done", "5_done",
"---\nname: Old Bug\n---\n", "---\nname: Old Bug\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Old Bug\n---\n"), crate::db::ItemMeta::named("Old Bug"),
); );
assert!(super::super::next_item_number(tmp.path()).unwrap() >= 6); assert!(super::super::next_item_number(tmp.path()).unwrap() >= 6);
} }
@@ -98,14 +97,14 @@ fn list_bug_files_excludes_archive_subdir() {
"7001_bug_open", "7001_bug_open",
"1_backlog", "1_backlog",
"---\nname: Open Bug\n---\n# Bug 7001: Open Bug\n", "---\nname: Open Bug\n---\n# Bug 7001: Open Bug\n",
crate::db::ItemMeta::from_yaml("---\nname: Open Bug\n---\n# Bug 7001: Open Bug\n"), crate::db::ItemMeta::named("Open Bug"),
); );
// Bug in done (should NOT appear — list_bug_files only returns Backlog). // Bug in done (should NOT appear — list_bug_files only returns Backlog).
crate::db::write_item_with_content( crate::db::write_item_with_content(
"7002_bug_closed", "7002_bug_closed",
"5_done", "5_done",
"---\nname: Closed Bug\n---\n# Bug 7002: Closed Bug\n", "---\nname: Closed Bug\n---\n# Bug 7002: Closed Bug\n",
crate::db::ItemMeta::from_yaml("---\nname: Closed Bug\n---\n# Bug 7002: Closed Bug\n"), crate::db::ItemMeta::named("Closed Bug"),
); );
let result = list_bug_files(tmp.path()).unwrap(); let result = list_bug_files(tmp.path()).unwrap();
@@ -125,19 +124,19 @@ fn list_bug_files_sorted_by_id() {
"7013_bug_third", "7013_bug_third",
"1_backlog", "1_backlog",
"---\nname: Third\n---\n# Bug 7013: Third\n", "---\nname: Third\n---\n# Bug 7013: Third\n",
crate::db::ItemMeta::from_yaml("---\nname: Third\n---\n# Bug 7013: Third\n"), crate::db::ItemMeta::named("Third"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"7011_bug_first", "7011_bug_first",
"1_backlog", "1_backlog",
"---\nname: First\n---\n# Bug 7011: First\n", "---\nname: First\n---\n# Bug 7011: First\n",
crate::db::ItemMeta::from_yaml("---\nname: First\n---\n# Bug 7011: First\n"), crate::db::ItemMeta::named("First"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"7012_bug_second", "7012_bug_second",
"1_backlog", "1_backlog",
"---\nname: Second\n---\n# Bug 7012: Second\n", "---\nname: Second\n---\n# Bug 7012: Second\n",
crate::db::ItemMeta::from_yaml("---\nname: Second\n---\n# Bug 7012: Second\n"), crate::db::ItemMeta::named("Second"),
); );
let result = list_bug_files(tmp.path()).unwrap(); let result = list_bug_files(tmp.path()).unwrap();
@@ -349,15 +348,8 @@ fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
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();
let contents = crate::db::read_content(&spike_id) let view = crate::crdt_state::read_item(&spike_id).expect("CRDT entry should exist");
.or_else(|| { assert_eq!(view.name(), Some(name));
let backlog = tmp.path().join(".huskies/work/1_backlog");
fs::read_to_string(backlog.join(format!("{spike_id}.md"))).ok()
})
.expect("spike content should exist");
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.name.as_deref(), Some(name));
} }
#[test] #[test]
@@ -369,7 +361,7 @@ fn create_spike_file_increments_from_existing_items() {
"7050_story_existing", "7050_story_existing",
"1_backlog", "1_backlog",
"---\nname: Existing\n---\n", "---\nname: Existing\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Existing\n---\n"), crate::db::ItemMeta::named("Existing"),
); );
let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap();
@@ -387,7 +379,8 @@ fn create_spike_file_increments_from_existing_items() {
// ── Bug 640: create_bug_file / create_refactor_file depends_on tests ──────── // ── Bug 640: create_bug_file / create_refactor_file depends_on tests ────────
#[test] #[test]
fn create_bug_file_with_depends_on_writes_front_matter_array() { fn create_bug_file_with_depends_on_persists_to_crdt() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path()); setup_git_repo(tmp.path());
@@ -403,26 +396,8 @@ fn create_bug_file_with_depends_on_writes_front_matter_array() {
) )
.unwrap(); .unwrap();
let contents = crate::db::read_content(&bug_id) let view = crate::crdt_state::read_item(&bug_id).expect("CRDT entry should exist");
.or_else(|| { assert_eq!(view.depends_on(), &[42, 43]);
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{bug_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("bug content should exist");
assert!(
contents.contains("depends_on: [42, 43]"),
"front matter should contain depends_on array: {contents}"
);
assert!(
!contents.contains("depends_on: \"["),
"depends_on must not be quoted string: {contents}"
);
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.depends_on, Some(vec![42, 43]));
} }
#[test] #[test]
@@ -458,29 +433,16 @@ fn create_bug_file_without_depends_on_omits_field() {
} }
#[test] #[test]
fn create_refactor_file_with_depends_on_writes_front_matter_array() { fn create_refactor_file_with_depends_on_persists_to_crdt() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path()); setup_git_repo(tmp.path());
let refactor_id = let refactor_id =
create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap(); create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap();
let contents = crate::db::read_content(&refactor_id) let view = crate::crdt_state::read_item(&refactor_id).expect("CRDT entry should exist");
.or_else(|| { assert_eq!(view.depends_on(), &[99]);
let filepath = tmp
.path()
.join(format!(".huskies/work/1_backlog/{refactor_id}.md"));
fs::read_to_string(filepath).ok()
})
.expect("refactor content should exist");
assert!(
contents.contains("depends_on: [99]"),
"front matter should contain depends_on array: {contents}"
);
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.depends_on, Some(vec![99]));
} }
#[test] #[test]
+1 -4
View File
@@ -16,10 +16,7 @@ pub use story_ops::{
add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file, add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file,
remove_criterion_from_file, update_story_in_file, remove_criterion_from_file, update_story_in_file,
}; };
pub use test_results::{ pub use test_results::{read_test_results_from_story_file, write_test_results_to_story_file};
read_test_results_from_story_file, write_coverage_baseline_to_story_file,
write_test_results_to_story_file,
};
pub(crate) use utils::{ pub(crate) use utils::{
create_section_content, next_item_number, read_story_content, replace_or_append_section, create_section_content, next_item_number, read_story_content, replace_or_append_section,
+19 -16
View File
@@ -308,7 +308,10 @@ mod tests {
id, id,
stage, stage,
&format!("---\nname: {id}\n---\n"), &format!("---\nname: {id}\n---\n"),
crate::db::ItemMeta::from_yaml(&format!("---\nname: {id}\n---\n")), crate::db::ItemMeta {
name: Some((*id).to_string()),
..Default::default()
},
); );
} }
@@ -353,7 +356,7 @@ mod tests {
"9860_story_test", "9860_story_test",
"2_current", "2_current",
"---\nname: Test Story\n---\n# Story\n", "---\nname: Test Story\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Test Story\n---\n# Story\n"), crate::db::ItemMeta::named("Test Story"),
); );
let ctx = crate::http::context::AppContext::new_test(root); let ctx = crate::http::context::AppContext::new_test(root);
@@ -389,7 +392,7 @@ mod tests {
"9861_story_done", "9861_story_done",
"2_current", "2_current",
"---\nname: Done Story\n---\n# Story\n", "---\nname: Done Story\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Done Story\n---\n# Story\n"), crate::db::ItemMeta::named("Done Story"),
); );
let ctx = crate::http::context::AppContext::new_test(root); let ctx = crate::http::context::AppContext::new_test(root);
@@ -422,7 +425,7 @@ mod tests {
"9862_story_pending", "9862_story_pending",
"2_current", "2_current",
"---\nname: Pending Story\n---\n# Story\n", "---\nname: Pending Story\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Pending Story\n---\n# Story\n"), crate::db::ItemMeta::named("Pending Story"),
); );
let ctx = crate::http::context::AppContext::new_test(root); let ctx = crate::http::context::AppContext::new_test(root);
@@ -448,20 +451,20 @@ mod tests {
#[test] #[test]
fn pipeline_state_includes_depends_on() { fn pipeline_state_includes_depends_on() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9863_story_dependent", "9863_story_dependent",
"1_backlog", "1_backlog",
"---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n", "# Dependent Story\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Dependent Story"),
"---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n",
),
); );
crate::crdt_state::set_depends_on("9863_story_dependent", &[10, 11]);
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9864_story_independent", "9864_story_independent",
"1_backlog", "1_backlog",
"---\nname: Independent Story\n---\n", "# Independent Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Independent Story\n---\n"), crate::db::ItemMeta::named("Independent Story"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -490,13 +493,13 @@ mod tests {
"9870_story_view_upcoming", "9870_story_view_upcoming",
"1_backlog", "1_backlog",
"---\nname: View Upcoming\n---\n# Story\n", "---\nname: View Upcoming\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: View Upcoming\n---\n# Story\n"), crate::db::ItemMeta::named("View Upcoming"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9871_story_worktree", "9871_story_worktree",
"1_backlog", "1_backlog",
"---\nname: Worktree Orchestration\n---\n# Story\n", "---\nname: Worktree Orchestration\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Worktree Orchestration\n---\n# Story\n"), crate::db::ItemMeta::named("Worktree Orchestration"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -523,7 +526,7 @@ mod tests {
"9872_story_example", "9872_story_example",
"1_backlog", "1_backlog",
"---\nname: A Story\n---\n", "---\nname: A Story\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: A Story\n---\n"), crate::db::ItemMeta::named("A Story"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -539,13 +542,13 @@ mod tests {
"9873_story_todos", "9873_story_todos",
"2_current", "2_current",
"---\nname: Show TODOs\n---\n# Story\n", "---\nname: Show TODOs\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Show TODOs\n---\n# Story\n"), crate::db::ItemMeta::named("Show TODOs"),
); );
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9874_story_front_matter", "9874_story_front_matter",
"1_backlog", "1_backlog",
"---\nname: Enforce Front Matter\n---\n# Story\n", "---\nname: Enforce Front Matter\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Enforce Front Matter\n---\n# Story\n"), crate::db::ItemMeta::named("Enforce Front Matter"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -591,7 +594,7 @@ mod tests {
"9876_story_no_name", "9876_story_no_name",
"2_current", "2_current",
"---\n---\n# Story\n", "---\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\n---\n# Story\n"), crate::db::ItemMeta::default(),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+17 -39
View File
@@ -66,7 +66,7 @@ pub fn create_story_file(
content.push_str("- TBD\n"); content.push_str("- TBD\n");
// 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, Some(name));
// Sync depends_on to the typed CRDT register. // Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[])); crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[]));
@@ -83,7 +83,6 @@ pub fn create_story_file(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs; use std::fs;
#[allow(dead_code)] #[allow(dead_code)]
@@ -146,7 +145,7 @@ mod tests {
"36_story_existing", "36_story_existing",
"1_backlog", "1_backlog",
"---\nname: Existing\n---\n", "---\nname: Existing\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Existing\n---\n"), crate::db::ItemMeta::named("Existing"),
); );
let number = super::super::super::next_item_number(tmp.path()).unwrap(); let number = super::super::super::next_item_number(tmp.path()).unwrap();
@@ -184,29 +183,24 @@ mod tests {
} }
#[test] #[test]
fn create_story_with_colon_in_name_produces_valid_yaml() { fn create_story_with_colon_in_name_persists_to_crdt() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = "Server-owned agent completion: remove report_completion dependency"; let name = "Server-owned agent completion: remove report_completion dependency";
let result = create_story_file(tmp.path(), name, None, None, None, None, false); let result = create_story_file(tmp.path(), name, None, None, None, None, false);
assert!(result.is_ok(), "create_story_file failed: {result:?}"); assert!(result.is_ok(), "create_story_file failed: {result:?}");
let story_id = result.unwrap(); let story_id = result.unwrap();
// Read from content store or filesystem. let view =
let content = crate::db::read_content(&story_id) crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create");
.or_else(|| { assert_eq!(view.name(), Some(name));
let backlog = tmp.path().join(".huskies/work/1_backlog");
fs::read_to_string(backlog.join(format!("{story_id}.md"))).ok()
})
.expect("story content should exist");
let meta = parse_front_matter(&content).expect("front matter should be valid YAML");
assert_eq!(meta.name.as_deref(), Some(name));
} }
// ── check_criterion_in_file tests ───────────────────────────────────────── // ── check_criterion_in_file tests ─────────────────────────────────────────
#[test] #[test]
fn create_story_with_depends_on_writes_front_matter_array() { fn create_story_with_depends_on_persists_to_crdt() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let story_id = create_story_file( let story_id = create_story_file(
tmp.path(), tmp.path(),
@@ -219,24 +213,9 @@ mod tests {
) )
.unwrap(); .unwrap();
let contents = crate::db::read_content(&story_id) let view =
.or_else(|| { crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create");
let backlog = tmp.path().join(".huskies/work/1_backlog"); assert_eq!(view.depends_on(), &[489]);
fs::read_to_string(backlog.join(format!("{story_id}.md"))).ok()
})
.expect("story content should exist");
assert!(
contents.contains("depends_on: [489]"),
"missing front matter: {contents}"
);
assert!(
!contents.contains("- [ ] depends_on"),
"must not appear as checkbox: {contents}"
);
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![489]));
} }
// ── Story 730: numeric-only story IDs ───────────────────────────────────── // ── Story 730: numeric-only story IDs ─────────────────────────────────────
@@ -258,19 +237,18 @@ mod tests {
} }
#[test] #[test]
fn create_story_file_writes_type_field_in_front_matter() { fn create_story_file_sets_item_type_register() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let story_id = let story_id =
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false) create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false)
.unwrap(); .unwrap();
let content = crate::db::read_content(&story_id).expect("content must exist"); let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist");
let meta = crate::db::yaml_legacy::parse_front_matter(&content)
.expect("front matter should be valid");
assert_eq!( assert_eq!(
meta.item_type.as_deref(), view.item_type(),
Some("story"), Some("story"),
"front matter must contain type: story" "CRDT register must be set to story"
); );
} }
@@ -52,7 +52,7 @@ pub fn check_criterion_in_file(
// Write back to content store. // Write back to content store.
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str); write_story_content(project_root, story_id, &stage, &new_str, None);
Ok(()) Ok(())
} }
@@ -99,7 +99,7 @@ pub fn remove_criterion_from_file(
} }
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str); write_story_content(project_root, story_id, &stage, &new_str, None);
Ok(()) Ok(())
} }
@@ -158,7 +158,7 @@ pub fn edit_criterion_in_file(
} }
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str); write_story_content(project_root, story_id, &stage, &new_str, None);
Ok(()) Ok(())
} }
@@ -219,7 +219,7 @@ pub fn add_criterion_to_file(
// Write back to content store. // Write back to content store.
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_str); write_story_content(project_root, story_id, &stage, &new_str, None);
Ok(()) Ok(())
} }
@@ -234,10 +234,7 @@ pub fn add_criterion_to_file(
/// - String → quoted unless it looks like a bool, integer, or inline sequence /// - String → quoted unless it looks like a bool, integer, or inline sequence
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::update_story_in_file;
use super::*; use super::*;
use serde_json::Value;
use std::collections::HashMap;
use std::fs; use std::fs;
#[allow(dead_code)] #[allow(dead_code)]
@@ -437,11 +434,8 @@ mod tests {
## Recommendation\n\n- TBD\n"; ## Recommendation\n\n- TBD\n";
setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content); setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content);
// Convert spike to story by updating the type field. // Convert spike to story by updating the typed item_type CRDT register.
let mut fields = HashMap::new(); crate::crdt_state::set_item_type("100_spike_my_spike", Some("story"));
fields.insert("type".to_string(), Value::String("story".to_string()));
update_story_in_file(tmp.path(), "100_spike_my_spike", None, None, Some(&fields))
.expect("converting spike type to story should succeed");
// Add three acceptance criteria. // Add three acceptance criteria.
add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion") add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion")
+30 -454
View File
@@ -1,7 +1,11 @@
//! update_story_in_file: replaces sections / writes front matter on existing stories. //! update_story_in_file: replace `## User Story` / `## Description` sections
//! in an existing story's content body.
//!
//! Story 929: the legacy `front_matter` HashMap parameter is gone — every
//! known typed field is intercepted in `tool_update_story` and routed to a
//! typed CRDT setter (set_name, set_agent, set_qa_mode, set_depends_on, …).
//! Any caller still trying to write to YAML front matter is a bug.
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
#[allow(unused_imports)] #[allow(unused_imports)]
@@ -10,48 +14,6 @@ use super::super::{
slugify_name, story_stage, write_story_content, slugify_name, story_stage, write_story_content,
}; };
use crate::db::yaml_legacy::set_front_matter_field;
fn json_value_to_yaml_scalar(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
format!("[{}]", items.join(", "))
}
Value::String(s) => yaml_encode_str(s),
// Null and Object are not meaningful as YAML scalars; store as quoted strings.
other => format!(
"\"{}\"",
other
.to_string()
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', "")
),
}
}
/// Encode a plain string as a YAML scalar.
///
/// Booleans (`true`/`false`), integers, and inline sequences (`[...]`) are
/// written unquoted. Everything else is quoted to avoid ambiguity.
fn yaml_encode_str(s: &str) -> String {
match s {
"true" | "false" => s.to_string(),
s if s.parse::<i64>().is_ok() => s.to_string(),
s if s.parse::<f64>().is_ok() => s.to_string(),
// YAML inline sequences like [490] or [490, 491] — write unquoted so
// serde_yaml can deserialise them as Vec<u32>.
s if s.starts_with('[') && s.ends_with(']') => s.to_string(),
s => format!(
"\"{}\"",
s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")
),
}
}
/// Update the user story text and/or description in a story. /// Update the user story text and/or description in a story.
/// ///
/// At least one of `user_story` or `description` must be provided. /// At least one of `user_story` or `description` must be provided.
@@ -61,32 +23,13 @@ pub fn update_story_in_file(
story_id: &str, story_id: &str,
user_story: Option<&str>, user_story: Option<&str>,
description: Option<&str>, description: Option<&str>,
front_matter: Option<&HashMap<String, Value>>,
) -> Result<(), String> { ) -> Result<(), String> {
let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false); if user_story.is_none() && description.is_none() {
if user_story.is_none() && description.is_none() && !has_front_matter_updates { return Err("At least one of 'user_story' or 'description' must be provided.".to_string());
return Err(
"At least one of 'user_story', 'description', or 'front_matter' must be provided."
.to_string(),
);
} }
let mut contents = read_story_content(project_root, story_id)?; let mut contents = read_story_content(project_root, story_id)?;
if let Some(fields) = front_matter {
if fields.contains_key("acceptance_criteria") {
return Err(
"'acceptance_criteria' is a reserved field managed via the story body \
(use add_criterion / remove_criterion / edit_criterion instead)."
.to_string(),
);
}
for (key, value) in fields {
let yaml_value = json_value_to_yaml_scalar(value);
contents = set_front_matter_field(&contents, key, &yaml_value);
}
}
if let Some(us) = user_story { if let Some(us) = user_story {
contents = match replace_section_content(&contents, "User Story", us) { contents = match replace_section_content(&contents, "User Story", us) {
Ok(updated) => updated, Ok(updated) => updated,
@@ -106,7 +49,7 @@ pub fn update_story_in_file(
// Write back to content store. // Write back to content store.
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &contents); write_story_content(project_root, story_id, &stage, &contents, None);
Ok(()) Ok(())
} }
@@ -114,98 +57,27 @@ pub fn update_story_in_file(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs; use std::fs;
#[allow(dead_code)] /// Helper: write a story to both the in-memory content store and the
fn setup_git_repo(root: &std::path::Path) { /// `.huskies/work/2_current/` directory so `read_story_content` picks it up
std::process::Command::new("git") /// regardless of which read path is used.
.args(["init"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(root)
.output()
.unwrap();
}
#[allow(dead_code)]
fn story_with_criteria(n: usize) -> String {
let mut s = "---\nname: Test Story\n---\n\n## Acceptance Criteria\n\n".to_string();
for i in 0..n {
s.push_str(&format!("- [ ] Criterion {i}\n"));
}
s
}
/// Helper to set up a story in the filesystem and content store for tests
/// that use check/add criterion.
#[allow(dead_code)]
fn setup_story_in_fs(root: &std::path::Path, story_id: &str, content: &str) { fn setup_story_in_fs(root: &std::path::Path, story_id: &str, content: &str) {
let current = root.join(".huskies/work/2_current"); let current = root.join(".huskies/work/2_current");
fs::create_dir_all(&current).unwrap(); fs::create_dir_all(&current).unwrap();
fs::write(current.join(format!("{story_id}.md")), content).unwrap(); fs::write(current.join(format!("{story_id}.md")), content).unwrap();
// Also write to the global content store so read_story_content picks up this
// content even when a previous test has left a stale entry for the same ID.
crate::db::ensure_content_store(); crate::db::ensure_content_store();
crate::db::write_content(story_id, content); crate::db::write_content(story_id, content);
} }
// --- create_story integration tests ---
#[test]
fn update_story_acceptance_criteria_in_front_matter_returns_error() {
// Bug 625: passing acceptance_criteria via front_matter must return an
// explicit error rather than silently dropping the value.
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"101_test",
"---\nname: T\n---\n\n## Acceptance Criteria\n\n- [ ] Existing\n",
);
let mut fields = HashMap::new();
fields.insert(
"acceptance_criteria".to_string(),
serde_json::json!(["crit 1", "crit 2"]),
);
let result = update_story_in_file(tmp.path(), "101_test", None, None, Some(&fields));
assert!(result.is_err(), "should fail with reserved-field error");
let err = result.unwrap_err();
assert!(
err.contains("acceptance_criteria"),
"error should name the reserved field: {err}"
);
}
// ── remove_criterion_from_file tests ──────────────────────────────────────
#[test] #[test]
fn update_story_replaces_user_story_section() { fn update_story_replaces_user_story_section() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n"; let content =
"# Story\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "20_test", content); setup_story_in_fs(tmp.path(), "20_test", content);
update_story_in_file( update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None).unwrap();
tmp.path(),
"20_test",
Some("New user story text"),
None,
None,
)
.unwrap();
let result = read_story_content(tmp.path(), "20_test").unwrap(); let result = read_story_content(tmp.path(), "20_test").unwrap();
assert!( assert!(
@@ -222,10 +94,11 @@ mod tests {
#[test] #[test]
fn update_story_replaces_description_section() { fn update_story_replaces_description_section() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n"; let content =
"# Story\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "21_test", content); setup_story_in_fs(tmp.path(), "21_test", content);
update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap(); update_story_in_file(tmp.path(), "21_test", None, Some("New description")).unwrap();
let result = read_story_content(tmp.path(), "21_test").unwrap(); let result = read_story_content(tmp.path(), "21_test").unwrap();
assert!( assert!(
@@ -241,9 +114,9 @@ mod tests {
#[test] #[test]
fn update_story_no_args_returns_error() { fn update_story_no_args_returns_error() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "22_test", "---\nname: T\n---\n"); setup_story_in_fs(tmp.path(), "22_test", "# Story\n");
let result = update_story_in_file(tmp.path(), "22_test", None, None, None); let result = update_story_in_file(tmp.path(), "22_test", None, None);
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("At least one")); assert!(result.unwrap_err().contains("At least one"));
} }
@@ -251,12 +124,10 @@ mod tests {
#[test] #[test]
fn update_story_creates_user_story_section_if_missing() { fn update_story_creates_user_story_section_if_missing() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
// Story with no ## User Story section but has ## Acceptance Criteria. let content = "# Story\n\n## Acceptance Criteria\n\n- [ ] AC\n";
let content = "---\nname: T\n---\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "23_test", content); setup_story_in_fs(tmp.path(), "23_test", content);
let result = let result = update_story_in_file(tmp.path(), "23_test", Some("New user story"), None);
update_story_in_file(tmp.path(), "23_test", Some("New user story"), None, None);
assert!( assert!(
result.is_ok(), result.is_ok(),
"should succeed when section is missing: {result:?}" "should succeed when section is missing: {result:?}"
@@ -268,7 +139,6 @@ mod tests {
"section should be created" "section should be created"
); );
assert!(updated.contains("New user story"), "text should be present"); assert!(updated.contains("New user story"), "text should be present");
// Section should appear before Acceptance Criteria.
let pos_us = updated.find("## User Story").unwrap(); let pos_us = updated.find("## User Story").unwrap();
let pos_ac = updated.find("## Acceptance Criteria").unwrap(); let pos_ac = updated.find("## Acceptance Criteria").unwrap();
assert!( assert!(
@@ -280,17 +150,12 @@ mod tests {
#[test] #[test]
fn update_story_creates_description_section_if_missing() { fn update_story_creates_description_section_if_missing() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
// Story with no ## Description section but has ## Acceptance Criteria. let content =
let content = "---\nname: T\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n- [ ] AC\n"; "# Story\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n- [ ] AC\n";
setup_story_in_fs(tmp.path(), "32_test", content); setup_story_in_fs(tmp.path(), "32_test", content);
let result = update_story_in_file( let result =
tmp.path(), update_story_in_file(tmp.path(), "32_test", None, Some("New description text"));
"32_test",
None,
Some("New description text"),
None,
);
assert!( assert!(
result.is_ok(), result.is_ok(),
"should succeed when section is missing: {result:?}" "should succeed when section is missing: {result:?}"
@@ -305,7 +170,6 @@ mod tests {
updated.contains("New description text"), updated.contains("New description text"),
"text should be present" "text should be present"
); );
// Section should appear before Acceptance Criteria.
let pos_desc = updated.find("## Description").unwrap(); let pos_desc = updated.find("## Description").unwrap();
let pos_ac = updated.find("## Acceptance Criteria").unwrap(); let pos_ac = updated.find("## Acceptance Criteria").unwrap();
assert!( assert!(
@@ -317,17 +181,11 @@ mod tests {
#[test] #[test]
fn update_story_creates_description_section_no_ac_section() { fn update_story_creates_description_section_no_ac_section() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
// Story with no ## Description and no ## Acceptance Criteria. let content = "# Story\n\nSome content here.\n";
let content = "---\nname: T\n---\n\nSome content here.\n";
setup_story_in_fs(tmp.path(), "33_test", content); setup_story_in_fs(tmp.path(), "33_test", content);
let result = update_story_in_file( let result =
tmp.path(), update_story_in_file(tmp.path(), "33_test", None, Some("Appended description"));
"33_test",
None,
Some("Appended description"),
None,
);
assert!( assert!(
result.is_ok(), result.is_ok(),
"should succeed even with no Acceptance Criteria: {result:?}" "should succeed even with no Acceptance Criteria: {result:?}"
@@ -343,286 +201,4 @@ mod tests {
"text should be present" "text should be present"
); );
} }
#[test]
fn update_story_sets_agent_front_matter_field() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"24_test",
"---\nname: T\n---\n\n## User Story\n\nSome story\n",
);
let mut fields = HashMap::new();
fields.insert("agent".to_string(), Value::String("dev".to_string()));
update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "24_test").unwrap();
assert!(
result.contains("agent: \"dev\""),
"agent field should be set"
);
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_sets_arbitrary_front_matter_fields() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"25_test",
"---\nname: T\n---\n\n## User Story\n\nSome story\n",
);
let mut fields = HashMap::new();
fields.insert("qa".to_string(), Value::String("human".to_string()));
fields.insert("priority".to_string(), Value::String("high".to_string()));
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "25_test").unwrap();
assert!(result.contains("qa: \"human\""), "qa field should be set");
assert!(
result.contains("priority: \"high\""),
"priority field should be set"
);
assert!(result.contains("name: T"), "name field preserved");
}
#[test]
fn update_story_front_matter_only_no_section_required() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"26_test",
"---\nname: T\n---\n\nNo sections here.\n",
);
let mut fields = HashMap::new();
fields.insert("agent".to_string(), Value::String("dev".to_string()));
let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields));
assert!(
result.is_ok(),
"front-matter-only update should not require body sections"
);
let contents = read_story_content(tmp.path(), "26_test").unwrap();
assert!(contents.contains("agent: \"dev\""));
}
#[test]
fn update_story_bool_front_matter_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "27_test", "---\nname: T\n---\n\nNo sections.\n");
// String "false" still works (backwards compatibility).
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::String("false".to_string()));
update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "27_test").unwrap();
assert!(
result.contains("blocked: false"),
"bool should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"false\""),
"bool must not be quoted: {result}"
);
}
#[test]
fn update_story_integer_front_matter_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "28_test", "---\nname: T\n---\n\nNo sections.\n");
// String "0" still works (backwards compatibility).
let mut fields = HashMap::new();
fields.insert("retry_count".to_string(), Value::String("0".to_string()));
update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "28_test").unwrap();
assert!(
result.contains("retry_count: 0"),
"integer should be unquoted: {result}"
);
assert!(
!result.contains("retry_count: \"0\""),
"integer must not be quoted: {result}"
);
}
#[test]
fn update_story_bool_front_matter_parseable_after_write() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"29_test",
"---\nname: My Story\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::String("false".to_string()));
update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap();
let contents = read_story_content(tmp.path(), "29_test").unwrap();
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(
meta.name.as_deref(),
Some("My Story"),
"name preserved after writing bool field"
);
}
// ── Bug 493 regression tests ──────────────────────────────────────────────
#[test]
fn update_story_depends_on_stored_as_yaml_array_not_quoted_string() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "30_test", "---\nname: T\n---\n\nNo sections.\n");
// String "[490]" still works (backwards compatibility).
let mut fields = HashMap::new();
fields.insert("depends_on".to_string(), Value::String("[490]".to_string()));
update_story_in_file(tmp.path(), "30_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "30_test").unwrap();
assert!(
result.contains("depends_on: [490]"),
"should be unquoted array: {result}"
);
assert!(
!result.contains("depends_on: \"[490]\""),
"must not be quoted: {result}"
);
let meta = parse_front_matter(&result).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![490]));
}
#[test]
fn update_story_native_bool_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "31_test", "---\nname: T\n---\n\nNo sections.\n");
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::Bool(false));
update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "31_test").unwrap();
assert!(
result.contains("blocked: false"),
"native bool false should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"false\""),
"must not be quoted: {result}"
);
}
#[test]
fn update_story_native_bool_true_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "32_test", "---\nname: T\n---\n\nNo sections.\n");
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::Bool(true));
update_story_in_file(tmp.path(), "32_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "32_test").unwrap();
assert!(
result.contains("blocked: true"),
"native bool true should be unquoted: {result}"
);
assert!(
!result.contains("blocked: \"true\""),
"must not be quoted: {result}"
);
}
#[test]
fn update_story_native_integer_written_unquoted() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"33b_test",
"---\nname: T\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("retry_count".to_string(), serde_json::json!(3));
update_story_in_file(tmp.path(), "33b_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "33b_test").unwrap();
assert!(
result.contains("retry_count: 3"),
"native integer should be unquoted: {result}"
);
assert!(
!result.contains("retry_count: \"3\""),
"must not be quoted: {result}"
);
}
#[test]
fn update_story_native_array_written_as_yaml_sequence() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "34_test", "---\nname: T\n---\n\nNo sections.\n");
let mut fields = HashMap::new();
fields.insert("depends_on".to_string(), serde_json::json!([490, 491]));
update_story_in_file(tmp.path(), "34_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "34_test").unwrap();
assert!(
result.contains("depends_on: [490, 491]"),
"native array should be YAML sequence: {result}"
);
assert!(
!result.contains("depends_on: \"["),
"must not be quoted: {result}"
);
let meta = parse_front_matter(&result).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![490, 491]));
}
#[test]
fn update_story_native_bool_parseable_after_write() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(
tmp.path(),
"35_test",
"---\nname: My Story\n---\n\nNo sections.\n",
);
let mut fields = HashMap::new();
fields.insert("blocked".to_string(), Value::Bool(false));
update_story_in_file(tmp.path(), "35_test", None, None, Some(&fields)).unwrap();
let contents = read_story_content(tmp.path(), "35_test").unwrap();
let meta = parse_front_matter(&contents).expect("front matter should parse");
assert_eq!(
meta.name.as_deref(),
Some("My Story"),
"name preserved after writing native bool"
);
}
#[test]
fn update_story_depends_on_multi_element_array() {
let tmp = tempfile::tempdir().unwrap();
setup_story_in_fs(tmp.path(), "31_test", "---\nname: T\n---\n\nNo sections.\n");
// String "[490, 491]" still works (backwards compatibility).
let mut fields = HashMap::new();
fields.insert(
"depends_on".to_string(),
Value::String("[490, 491]".to_string()),
);
update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap();
let result = read_story_content(tmp.path(), "31_test").unwrap();
let meta = parse_front_matter(&result).expect("front matter should parse");
assert_eq!(meta.depends_on, Some(vec![490, 491]));
}
} }
+6 -63
View File
@@ -1,5 +1,4 @@
//! Test result persistence — writes structured test results into story markdown files. //! Test result persistence — writes structured test results into story markdown files.
use crate::db::yaml_legacy::set_front_matter_field;
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
use std::path::Path; use std::path::Path;
@@ -27,7 +26,7 @@ pub fn write_test_results_to_story_file(
// Write back to content store and CRDT. // Write back to content store and CRDT.
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &new_contents); write_story_content(project_root, story_id, &stage, &new_contents, None);
Ok(()) Ok(())
} }
@@ -43,31 +42,6 @@ pub fn read_test_results_from_story_file(
parse_test_results_from_contents(&contents) parse_test_results_from_contents(&contents)
} }
/// Write coverage baseline to the front matter of a story.
///
/// If the story is not found, this is a no-op (returns Ok).
pub fn write_coverage_baseline_to_story_file(
project_root: &Path,
story_id: &str,
coverage_pct: f64,
) -> Result<(), String> {
let contents = match read_story_content(project_root, story_id) {
Ok(c) => c,
Err(_) => return Ok(()), // No story — skip silently
};
let updated = set_front_matter_field(
&contents,
"coverage_baseline",
&format!("{coverage_pct:.1}%"),
);
let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string());
write_story_content(project_root, story_id, &stage, &updated);
Ok(())
}
/// Build the `## Test Results` section text including JSON comment and human-readable summary. /// Build the `## Test Results` section text including JSON comment and human-readable summary.
fn build_test_results_section(json: &str, results: &StoryTestResults) -> String { fn build_test_results_section(json: &str, results: &StoryTestResults) -> String {
let mut s = String::from("## Test Results\n\n"); let mut s = String::from("## Test Results\n\n");
@@ -176,7 +150,7 @@ mod tests {
"8001_story_test", "8001_story_test",
"2_current", "2_current",
"---\nname: Test\n---\n# Story\n", "---\nname: Test\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n# Story\n"), crate::db::ItemMeta::named("Test"),
); );
let results = make_results(); let results = make_results();
@@ -202,9 +176,7 @@ mod tests {
"8002_story_check", "8002_story_check",
"2_current", "2_current",
"---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n", "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Check"),
"---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n",
),
); );
let results = make_results(); let results = make_results();
@@ -227,9 +199,7 @@ mod tests {
"8003_story_overwrite", "8003_story_overwrite",
"2_current", "2_current",
"---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n<!-- huskies-test-results: {} -->\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n<!-- huskies-test-results: {} -->\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n",
crate::db::ItemMeta::from_yaml( crate::db::ItemMeta::named("Overwrite"),
"---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n<!-- huskies-test-results: {} -->\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n",
),
); );
let results = make_results(); let results = make_results();
@@ -249,7 +219,7 @@ mod tests {
"8004_story_empty", "8004_story_empty",
"2_current", "2_current",
"---\nname: Empty\n---\n# Story\n", "---\nname: Empty\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Empty\n---\n# Story\n"), crate::db::ItemMeta::named("Empty"),
); );
let result = read_test_results_from_story_file(tmp.path(), "8004_story_empty"); let result = read_test_results_from_story_file(tmp.path(), "8004_story_empty");
@@ -271,7 +241,7 @@ mod tests {
"8005_story_qa", "8005_story_qa",
"3_qa", "3_qa",
"---\nname: QA Story\n---\n# Story\n", "---\nname: QA Story\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: QA Story\n---\n# Story\n"), crate::db::ItemMeta::named("QA Story"),
); );
let results = StoryTestResults { let results = StoryTestResults {
@@ -287,31 +257,4 @@ mod tests {
let read_back = read_test_results_from_story_file(tmp.path(), "8005_story_qa").unwrap(); let read_back = read_test_results_from_story_file(tmp.path(), "8005_story_qa").unwrap();
assert_eq!(read_back.unit.len(), 1); assert_eq!(read_back.unit.len(), 1);
} }
#[test]
fn write_coverage_baseline_to_story_file_updates_front_matter() {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"8006_story_cov",
"2_current",
"---\nname: Cov Story\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Cov Story\n---\n# Story\n"),
);
write_coverage_baseline_to_story_file(tmp.path(), "8006_story_cov", 75.4).unwrap();
let contents = read_story_content(tmp.path(), "8006_story_cov").unwrap();
assert!(
contents.contains("coverage_baseline: 75.4%"),
"got: {contents}"
);
}
#[test]
fn write_coverage_baseline_to_story_file_silent_on_missing_story() {
let tmp = tempfile::tempdir().unwrap();
let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0);
assert!(result.is_ok());
}
} }
+12 -8
View File
@@ -11,19 +11,23 @@ pub(crate) fn read_story_content(_project_root: &Path, story_id: &str) -> Result
} }
/// Write story content to the DB content store and CRDT. /// Write story content to the DB content store and CRDT.
///
/// Pass `Some(name)` when creating a new item or renaming an existing one,
/// `None` to leave the existing name register untouched. The CRDT is the
/// single source of truth for every metadata field — callers must use the
/// typed setters (`crdt_state::set_depends_on`, `set_item_type`, …) for
/// anything beyond name.
pub(crate) fn write_story_content( pub(crate) fn write_story_content(
_project_root: &Path, _project_root: &Path,
story_id: &str, story_id: &str,
stage: &str, stage: &str,
content: &str, content: &str,
name: Option<&str>,
) { ) {
let mut meta = crate::db::ItemMeta::from_yaml(content); let meta = crate::db::ItemMeta {
// CRDT is the single source of truth for depends_on. Never overwrite the name: name.map(str::to_string),
// register from YAML here — the typed setter (crdt_state::set_depends_on) ..Default::default()
// is the only authorised write path. Passing None leaves the existing };
// register untouched on update and initialises new items to "" so the
// explicit set_depends_on call in each create function takes effect.
meta.depends_on = None;
crate::db::write_item_with_content(story_id, stage, content, meta); crate::db::write_item_with_content(story_id, stage, content, meta);
} }
@@ -273,7 +277,7 @@ mod tests {
"9877_story_foo", "9877_story_foo",
"1_backlog", "1_backlog",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), crate::db::ItemMeta::named("Foo"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
assert!(next_item_number(tmp.path()).unwrap() >= 9878); assert!(next_item_number(tmp.path()).unwrap() >= 9878);
+3 -3
View File
@@ -81,7 +81,7 @@ fn sweep_moves_old_items_to_archived() {
"9880_story_sweep_old", "9880_story_sweep_old",
"5_done", "5_done",
"---\nname: old\n---\n", "---\nname: old\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: old\n---\n"), crate::db::ItemMeta::named("old"),
); );
// With ZERO retention, any Done item should be swept. // With ZERO retention, any Done item should be swept.
@@ -105,7 +105,7 @@ fn sweep_keeps_recent_items_in_done() {
"9881_story_sweep_new", "9881_story_sweep_new",
"5_done", "5_done",
"---\nname: new\n---\n", "---\nname: new\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: new\n---\n"), crate::db::ItemMeta::named("new"),
); );
// With a very long retention, the item (merged_at ≈ now) should stay. // With a very long retention, the item (merged_at ≈ now) should stay.
@@ -128,7 +128,7 @@ fn sweep_respects_custom_retention() {
"9882_story_sweep_custom", "9882_story_sweep_custom",
"5_done", "5_done",
"---\nname: custom\n---\n", "---\nname: custom\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: custom\n---\n"), crate::db::ItemMeta::named("custom"),
); );
// With ZERO retention, sweep should promote. // With ZERO retention, sweep should promote.
+4 -4
View File
@@ -658,7 +658,7 @@ fn regression_freeze_unfreeze_restores_crdt_stage() {
story_id, story_id,
"2_current", "2_current",
content, content,
crate::db::ItemMeta::from_yaml(content), crate::db::ItemMeta::named("Freeze Regression"),
); );
// Confirm starting stage. // Confirm starting stage.
@@ -711,7 +711,7 @@ fn merge_failure_transition_emits_event_with_full_reason() {
story_id, story_id,
"4_merge", "4_merge",
"---\nname: Merge Failure Event Test\n---\n# Story\n", "---\nname: Merge Failure Event Test\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: Merge Failure Event Test\n---\n# Story\n"), crate::db::ItemMeta::named("Merge Failure Event Test"),
); );
let reason = "Conflict in server/src/main.rs: both modified"; let reason = "Conflict in server/src/main.rs: both modified";
@@ -783,7 +783,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
story_id, story_id,
"4_merge_failure", "4_merge_failure",
"---\nname: MergeFailure Self-loop Test\n---\n# Story\n", "---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: MergeFailure Self-loop Test\n---\n# Story\n"), crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
); );
// Apply a second MergeFailed to a story already in MergeFailure. // Apply a second MergeFailed to a story already in MergeFailure.
@@ -856,7 +856,7 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
story_id, story_id,
"4_merge_failure", "4_merge_failure",
"---\nname: MergeFailure Accept Test\n---\n# Story\n", "---\nname: MergeFailure Accept Test\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: MergeFailure Accept Test\n---\n# Story\n"), crate::db::ItemMeta::named("MergeFailure Accept Test"),
); );
let fired = super::apply::apply_transition(story_id, PipelineEvent::Accepted, None) let fired = super::apply::apply_transition(story_id, PipelineEvent::Accepted, None)
@@ -18,7 +18,7 @@ async fn rate_limit_warning_sends_notification_with_agent_and_story() {
"365_story_rate_limit", "365_story_rate_limit",
"2_current", "2_current",
"---\nname: Rate Limit Test Story\n---\n", "---\nname: Rate Limit Test Story\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Rate Limit Test Story\n---\n"), crate::db::ItemMeta::named("Rate Limit Test Story"),
); );
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16); let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
@@ -145,7 +145,7 @@ async fn story_blocked_sends_notification_with_reason() {
"425_story_blocking_test", "425_story_blocking_test",
"2_current", "2_current",
"---\nname: Blocking Test Story\n---\n", "---\nname: Blocking Test Story\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Blocking Test Story\n---\n"), crate::db::ItemMeta::named("Blocking Test Story"),
); );
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16); let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
@@ -21,7 +21,7 @@ async fn stage_notification_uses_dynamic_room_ids() {
"10_story_foo", "10_story_foo",
"3_qa", "3_qa",
"---\nname: Foo Story\n---\n", "---\nname: Foo Story\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Foo Story\n---\n"), crate::db::ItemMeta::named("Foo Story"),
); );
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16); let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
@@ -111,7 +111,7 @@ fn read_story_name_reads_from_front_matter() {
"9942_story_my_feature", "9942_story_my_feature",
"2_current", "2_current",
"---\nname: My Cool Feature\n---\n# Story\n", "---\nname: My Cool Feature\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\nname: My Cool Feature\n---\n# Story\n"), crate::db::ItemMeta::named("My Cool Feature"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -134,7 +134,7 @@ fn read_story_name_returns_none_for_missing_name_field() {
"9943_story_no_name", "9943_story_no_name",
"2_current", "2_current",
"---\ncoverage_baseline: 50%\n---\n# Story\n", "---\ncoverage_baseline: 50%\n---\n# Story\n",
crate::db::ItemMeta::from_yaml("---\ncoverage_baseline: 50%\n---\n# Story\n"), crate::db::ItemMeta::default(),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -214,7 +214,7 @@ mod tests {
story_id, story_id,
"1_backlog", "1_backlog",
"---\nname: Service Delete Regression\n---\n", "---\nname: Service Delete Regression\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Service Delete Regression\n---\n"), crate::db::ItemMeta::named("Service Delete Regression"),
); );
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+6 -6
View File
@@ -79,7 +79,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Freeze Service Test\n---\n", "---\nname: Freeze Service Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Freeze Service Test\n---\n"), crate::db::ItemMeta::named("Freeze Service Test"),
); );
let result = freeze(story_id); let result = freeze(story_id);
@@ -106,7 +106,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Already Frozen\n---\n", "---\nname: Already Frozen\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Already Frozen\n---\n"), crate::db::ItemMeta::named("Already Frozen"),
); );
freeze(story_id).expect("first freeze should succeed"); freeze(story_id).expect("first freeze should succeed");
@@ -126,7 +126,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Unfreeze Service Test\n---\n", "---\nname: Unfreeze Service Test\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Unfreeze Service Test\n---\n"), crate::db::ItemMeta::named("Unfreeze Service Test"),
); );
freeze(story_id).expect("freeze should succeed"); freeze(story_id).expect("freeze should succeed");
@@ -155,7 +155,7 @@ mod tests {
story_id, story_id,
"2_current", "2_current",
"---\nname: Not Frozen\n---\n", "---\nname: Not Frozen\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Not Frozen\n---\n"), crate::db::ItemMeta::named("Not Frozen"),
); );
let result = unfreeze(story_id); let result = unfreeze(story_id);
@@ -179,7 +179,7 @@ mod tests {
story_a, story_a,
"2_current", "2_current",
"---\nname: Regression Chat Path\n---\n", "---\nname: Regression Chat Path\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Regression Chat Path\n---\n"), crate::db::ItemMeta::named("Regression Chat Path"),
); );
// Story B simulates the MCP tool path. // Story B simulates the MCP tool path.
@@ -188,7 +188,7 @@ mod tests {
story_b, story_b,
"2_current", "2_current",
"---\nname: Regression MCP Path\n---\n", "---\nname: Regression MCP Path\n---\n",
crate::db::ItemMeta::from_yaml("---\nname: Regression MCP Path\n---\n"), crate::db::ItemMeta::named("Regression MCP Path"),
); );
// Both paths call service::work_item::freeze(). // Both paths call service::work_item::freeze().