From 69d91d7707034474daefd0d5a37cf0e142d4b67b Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 12 May 2026 20:55:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(929):=20delete=20db/yaml=5Flegacy.rs=20ent?= =?UTF-8?q?irely=20=E2=80=94=20CRDT=20is=20the=20sole=20source=20of=20trut?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agents/pool/auto_assign/auto_assign.rs | 53 +- server/src/agents/pool/auto_assign/scan.rs | 8 +- .../src/agents/pool/pipeline/advance/tests.rs | 2 +- .../pool/pipeline/advance/tests_regression.rs | 18 +- .../agents/pool/pipeline/completion/tests.rs | 2 +- .../src/agents/pool/pipeline/merge/tests.rs | 6 +- server/src/agents/pool/start/spawn.rs | 6 +- .../agents/pool/start/tests_concurrency.rs | 6 +- server/src/agents/pool/stop.rs | 2 +- server/src/agents/pool/worktree.rs | 6 +- server/src/chat/commands/depends.rs | 6 + server/src/chat/commands/diff.rs | 1 + server/src/chat/commands/freeze.rs | 8 +- server/src/chat/commands/logs.rs | 2 + server/src/chat/commands/move_story.rs | 7 +- server/src/chat/commands/show.rs | 3 + server/src/chat/commands/status/tests.rs | 6 +- server/src/chat/commands/triage.rs | 20 +- server/src/chat/commands/unblock.rs | 12 +- server/src/chat/lookup.rs | 4 + server/src/chat/test_helpers.rs | 19 +- server/src/chat/transport/matrix/assign.rs | 5 + server/src/chat/transport/matrix/delete.rs | 6 +- server/src/chat/transport/matrix/start.rs | 2 +- server/src/crdt_state/mod.rs | 2 +- server/src/crdt_state/write/item.rs | 23 + server/src/crdt_state/write/mod.rs | 2 +- server/src/db/mod.rs | 28 +- server/src/db/ops.rs | 28 +- server/src/db/yaml_legacy.rs | 166 ------ server/src/http/agents/tests.rs | 4 +- server/src/http/mcp/agent_tools/lifecycle.rs | 51 +- server/src/http/mcp/diagnostics/permission.rs | 6 +- server/src/http/mcp/status_tools.rs | 12 +- server/src/http/mcp/story_tools/bug.rs | 14 +- server/src/http/mcp/story_tools/criteria.rs | 24 +- .../src/http/mcp/story_tools/story/delete.rs | 2 +- .../src/http/mcp/story_tools/story/freeze.rs | 4 +- .../src/http/mcp/story_tools/story/query.rs | 11 +- .../src/http/mcp/story_tools/story/update.rs | 381 ++++---------- server/src/http/workflow/bug_ops/bug.rs | 2 +- server/src/http/workflow/bug_ops/epic.rs | 2 +- server/src/http/workflow/bug_ops/refactor.rs | 2 +- server/src/http/workflow/bug_ops/spike.rs | 2 +- server/src/http/workflow/bug_ops/tests.rs | 76 +-- server/src/http/workflow/mod.rs | 5 +- server/src/http/workflow/pipeline.rs | 35 +- server/src/http/workflow/story_ops/create.rs | 56 +- .../src/http/workflow/story_ops/criterion.rs | 18 +- server/src/http/workflow/story_ops/update.rs | 484 ++---------------- server/src/http/workflow/test_results.rs | 69 +-- server/src/http/workflow/utils.rs | 20 +- server/src/io/watcher/tests.rs | 6 +- server/src/pipeline_state/tests.rs | 8 +- .../notifications/io/tests_notifications.rs | 4 +- .../service/notifications/io/tests_stage.rs | 6 +- server/src/service/work_item/delete.rs | 2 +- server/src/service/work_item/freeze.rs | 12 +- 58 files changed, 433 insertions(+), 1344 deletions(-) delete mode 100644 server/src/db/yaml_legacy.rs diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index 6b4a896d..ce2a3003 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -293,9 +293,10 @@ mod tests { crate::db::write_item_with_content( "9932_story_waiting", "2_current", - "---\nname: Waiting\ndepends_on: [9999]\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Waiting\ndepends_on: [9999]\n---\n"), + "# Waiting\n", + crate::db::ItemMeta::named("Waiting"), ); + crate::crdt_state::set_depends_on("9932_story_waiting", &[9999]); let pool = AgentPool::new_test(3001); pool.auto_assign_available_work(root).await; @@ -332,15 +333,16 @@ mod tests { "999_story_dep", "5_done", "---\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. crate::db::write_item_with_content( "10_story_unblocked", "2_current", - "---\nname: Unblocked\ndepends_on: [999]\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Unblocked\ndepends_on: [999]\n---\n"), + "# Unblocked\n", + crate::db::ItemMeta::named("Unblocked"), ); + crate::crdt_state::set_depends_on("10_story_unblocked", &[999]); let pool = AgentPool::new_test(3001); pool.auto_assign_available_work(root).await; @@ -523,10 +525,8 @@ mod tests { crate::db::write_item_with_content( "9860_story_conflict", "4_merge_failure", - "---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Conflict\nmerge_failure: \"CONFLICT (content): server/src/lib.rs\"\n---\n", - ), + "CONFLICT (content): server/src/lib.rs", + crate::db::ItemMeta::named("Conflict"), ); let pool = AgentPool::new_test(3001); @@ -561,10 +561,8 @@ mod tests { crate::db::write_item_with_content( "9861_story_nothing", "4_merge_failure", - "---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Nothing\nmerge_failure: \"nothing to commit, working tree clean\"\n---\n", - ), + "nothing to commit, working tree clean", + crate::db::ItemMeta::named("Nothing"), ); let pool = AgentPool::new_test(3001); @@ -599,10 +597,12 @@ mod tests { crate::db::write_item_with_content( "9863_story_blocked_conflict", "4_merge", - "---\nname: Blocked conflict\nmerge_failure: \"CONFLICT (content): foo.rs\"\nblocked: true\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Blocked conflict\nmerge_failure: \"CONFLICT (content): foo.rs\"\nblocked: true\n---\n", - ), + "CONFLICT (content): foo.rs", + crate::db::ItemMeta { + name: Some("Blocked conflict".to_string()), + blocked: Some(true), + ..Default::default() + }, ); let pool = AgentPool::new_test(3001); @@ -636,11 +636,10 @@ mod tests { crate::db::write_item_with_content( "9862_story_attempted", "4_merge", - "---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Already tried\nmerge_failure: \"CONFLICT (content): foo.rs\"\nmergemaster_attempted: true\n---\n", - ), + "CONFLICT (content): foo.rs", + crate::db::ItemMeta::named("Already tried"), ); + crate::crdt_state::set_mergemaster_attempted("9862_story_attempted", true); let pool = AgentPool::new_test(3001); pool.auto_assign_available_work(tmp.path()).await; @@ -677,10 +676,8 @@ mod tests { crate::db::write_item_with_content( "920_story_transient", "4_merge_failure", - "---\nname: Transient\nmerge_failure: \"CONFLICT (content): foo.rs\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Transient\nmerge_failure: \"CONFLICT (content): foo.rs\"\n---\n", - ), + "CONFLICT (content): foo.rs", + crate::db::ItemMeta::named("Transient"), ); // Simulate two previous transient exits (below cap of 3) recorded in DB. crate::db::write_content("920_story_transient:mergemaster_spawn_count", "2"); @@ -719,10 +716,8 @@ mod tests { crate::db::write_item_with_content( "920_story_genuine", "4_merge_failure", - "---\nname: Genuine\nmerge_failure: \"CONFLICT (content): bar.rs\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Genuine\nmerge_failure: \"CONFLICT (content): bar.rs\"\n---\n", - ), + "CONFLICT (content): bar.rs", + crate::db::ItemMeta::named("Genuine"), ); // The CRDT register is the sole authority; set it explicitly as the // spawn exit path would after report_merge_failure. diff --git a/server/src/agents/pool/auto_assign/scan.rs b/server/src/agents/pool/auto_assign/scan.rs index 277466ae..30e1a2a0 100644 --- a/server/src/agents/pool/auto_assign/scan.rs +++ b/server/src/agents/pool/auto_assign/scan.rs @@ -168,7 +168,7 @@ mod tests { "9970_story_archived", "6_archived", "---\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. @@ -205,19 +205,19 @@ mod tests { "9942_story_foo", "2_current", "---\nname: foo\n---", - crate::db::ItemMeta::from_yaml("---\nname: foo\n---"), + crate::db::ItemMeta::named("foo"), ); crate::db::write_item_with_content( "9940_story_bar", "2_current", "---\nname: bar\n---", - crate::db::ItemMeta::from_yaml("---\nname: bar\n---"), + crate::db::ItemMeta::named("bar"), ); crate::db::write_item_with_content( "9935_story_baz", "2_current", "---\nname: baz\n---", - crate::db::ItemMeta::from_yaml("---\nname: baz\n---"), + crate::db::ItemMeta::named("baz"), ); let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/agents/pool/pipeline/advance/tests.rs b/server/src/agents/pool/pipeline/advance/tests.rs index 7cba7d40..0dbbd7e9 100644 --- a/server/src/agents/pool/pipeline/advance/tests.rs +++ b/server/src/agents/pool/pipeline/advance/tests.rs @@ -176,7 +176,7 @@ async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() { "173_story_test", "2_current", "---\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. diff --git a/server/src/agents/pool/pipeline/advance/tests_regression.rs b/server/src/agents/pool/pipeline/advance/tests_regression.rs index e8201c36..997c09b3 100644 --- a/server/src/agents/pool/pipeline/advance/tests_regression.rs +++ b/server/src/agents/pool/pipeline/advance/tests_regression.rs @@ -51,7 +51,7 @@ async fn mergemaster_blocks_and_sends_story_blocked_when_no_commits_ahead() { "9919_story_no_commits", "2_current", "---\nname: Test\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + crate::db::ItemMeta::named("Test"), ); let pool = AgentPool::new_test(3001); @@ -146,13 +146,13 @@ stage = "qa" "292_story_first", "3_qa", "---\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( "293_story_second", "3_qa", "---\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); @@ -252,7 +252,7 @@ async fn stale_mergemaster_advance_for_done_story_is_noop() { story_id, "5_done", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::named("Zombie Merge Test"), ); let pool = AgentPool::new_test(3001); @@ -389,7 +389,7 @@ async fn work_survived_advances_to_qa_instead_of_blocking() { "9945_story_survived", "2_current", "---\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): @@ -483,7 +483,7 @@ async fn no_committed_work_still_retries_and_blocks() { "9946_story_nowork", "2_current", "---\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. @@ -611,7 +611,7 @@ async fn gates_failed_no_test_evidence_does_not_advance() { "9947_story_no_evidence", "2_current", "---\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. @@ -741,7 +741,7 @@ async fn gates_failed_with_test_evidence_and_committed_work_advances() { "9948_story_with_evidence", "2_current", "---\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 @@ -825,7 +825,7 @@ stage = "coder" "9950_story_warm_resume", "2_current", "---\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); diff --git a/server/src/agents/pool/pipeline/completion/tests.rs b/server/src/agents/pool/pipeline/completion/tests.rs index 8cad2265..72cf91c7 100644 --- a/server/src/agents/pool/pipeline/completion/tests.rs +++ b/server/src/agents/pool/pipeline/completion/tests.rs @@ -553,7 +553,7 @@ async fn zero_commit_coder_exit_stays_in_coding_not_promoted_to_merge() { "9910_zero_exit", "2_current", "---\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(); diff --git a/server/src/agents/pool/pipeline/merge/tests.rs b/server/src/agents/pool/pipeline/merge/tests.rs index 6f56b060..e20f1b57 100644 --- a/server/src/agents/pool/pipeline/merge/tests.rs +++ b/server/src/agents/pool/pipeline/merge/tests.rs @@ -651,7 +651,7 @@ async fn server_side_merge_happy_path_advances_to_done() { "757a_happy", "4_merge", "---\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)); @@ -788,7 +788,7 @@ async fn server_side_merge_conflict_sets_merge_failure() { "757b_conflict", "4_merge", "---\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)); @@ -901,7 +901,7 @@ async fn server_side_merge_gate_failure_sets_merge_failure() { "757c_gates", "4_merge", "---\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)); diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index c9e877c1..938857fd 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -641,7 +641,7 @@ mod tests { story_id, "2_current", "---\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); @@ -679,7 +679,7 @@ mod tests { story_id, "2_current", "---\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). @@ -744,7 +744,7 @@ mod tests { story_id, "2_current", "---\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"); diff --git a/server/src/agents/pool/start/tests_concurrency.rs b/server/src/agents/pool/start/tests_concurrency.rs index 7c4940b0..9aabde99 100644 --- a/server/src/agents/pool/start/tests_concurrency.rs +++ b/server/src/agents/pool/start/tests_concurrency.rs @@ -433,7 +433,7 @@ async fn start_agent_rejects_mergemaster_on_coding_stage_story() { "310_story_foo", "2_current", "---\nname: Foo\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), + crate::db::ItemMeta::named("Foo"), ); let pool = AgentPool::new_test(3099); @@ -472,7 +472,7 @@ async fn start_agent_rejects_coder_on_qa_stage_story() { "8842_story_qa_guard", "3_qa", "---\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); @@ -511,7 +511,7 @@ async fn start_agent_rejects_qa_on_merge_stage_story() { "55_story_baz", "4_merge", "---\nname: Baz\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Baz\n---\n"), + crate::db::ItemMeta::named("Baz"), ); let pool = AgentPool::new_test(3099); diff --git a/server/src/agents/pool/stop.rs b/server/src/agents/pool/stop.rs index 91bc7e01..4273bc55 100644 --- a/server/src/agents/pool/stop.rs +++ b/server/src/agents/pool/stop.rs @@ -147,7 +147,7 @@ mod tests { "60_story_cleanup", "2_current", story_content, - crate::db::ItemMeta::from_yaml(story_content), + crate::db::ItemMeta::default(), ); let pool = AgentPool::new_test(3001); diff --git a/server/src/agents/pool/worktree.rs b/server/src/agents/pool/worktree.rs index 07a88aa0..a730ecf2 100644 --- a/server/src/agents/pool/worktree.rs +++ b/server/src/agents/pool/worktree.rs @@ -46,7 +46,7 @@ mod tests { "10_story_test", "2_current", "---\nname: Test\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + crate::db::ItemMeta::named("Test"), ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( @@ -62,7 +62,7 @@ mod tests { "11_story_test", "3_qa", "---\nname: Test\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + crate::db::ItemMeta::named("Test"), ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( @@ -78,7 +78,7 @@ mod tests { "12_story_test", "4_merge", "---\nname: Test\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + crate::db::ItemMeta::named("Test"), ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( diff --git a/server/src/chat/commands/depends.rs b/server/src/chat/commands/depends.rs index 9cde9f91..93b9cb03 100644 --- a/server/src/chat/commands/depends.rs +++ b/server/src/chat/commands/depends.rs @@ -166,6 +166,7 @@ mod tests { "1_backlog", "9912_story_foo.md", "---\nname: Foo\n---\n", + None, ); let output = depends_cmd_with_root(tmp.path(), "9912 abc").unwrap(); assert!( @@ -182,6 +183,7 @@ mod tests { "1_backlog", "9910_story_foo.md", "---\nname: Foo\n---\n", + None, ); let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap(); assert!( @@ -212,6 +214,7 @@ mod tests { "2_current", "9911_story_bar.md", "---\nname: Bar\n---\n", + None, ); // Pre-seed CRDT with deps so we can verify clearing. crate::crdt_state::set_depends_on("9911_story_bar", &[477]); @@ -248,6 +251,7 @@ mod tests { "1_backlog", "8790_story_chat_dep.md", "---\nname: Chat Dep\n---\n", + None, ); let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap(); @@ -282,6 +286,7 @@ mod tests { "1_backlog", "9920_story_scr.md", "---\nname: SCR\n---\n", + None, ); // Set to [1, 2, 3]. @@ -322,6 +327,7 @@ mod tests { "3_qa", "9913_story_inqa.md", "---\nname: In QA\n---\n", + None, ); let output = depends_cmd_with_root(tmp.path(), "9913 100").unwrap(); assert!( diff --git a/server/src/chat/commands/diff.rs b/server/src/chat/commands/diff.rs index 83009000..c4b2b258 100644 --- a/server/src/chat/commands/diff.rs +++ b/server/src/chat/commands/diff.rs @@ -212,6 +212,7 @@ mod tests { "2_current", "55551_story_no_worktree.md", "---\nname: No Worktree\n---\n", + None, ); let output = diff_cmd(tmp.path(), "55551").unwrap(); assert!( diff --git a/server/src/chat/commands/freeze.rs b/server/src/chat/commands/freeze.rs index fb286504..4254c4dd 100644 --- a/server/src/chat/commands/freeze.rs +++ b/server/src/chat/commands/freeze.rs @@ -193,7 +193,8 @@ mod tests { tmp.path(), "2_current", "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(); assert!( @@ -219,7 +220,8 @@ mod tests { tmp.path(), "2_current", "9941_story_frozen.md", - "---\nname: Frozen Story\n---\n# Story\n", + "# Story\n", + Some("Frozen Story"), ); // Freeze first. let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap(); @@ -253,6 +255,7 @@ mod tests { "2_current", "9942_story_notfrozen.md", "---\nname: Not Frozen\n---\n# Story\n", + None, ); let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap(); assert!( @@ -271,6 +274,7 @@ mod tests { "2_current", "9943_story_alreadyfrozen.md", "---\nname: Already Frozen\n---\n# Story\n", + None, ); // Freeze it first. freeze_cmd_with_root(tmp.path(), "9943").unwrap(); diff --git a/server/src/chat/commands/logs.rs b/server/src/chat/commands/logs.rs index 6a5a6fa6..763b3cc9 100644 --- a/server/src/chat/commands/logs.rs +++ b/server/src/chat/commands/logs.rs @@ -202,6 +202,7 @@ mod tests { "2_current", "77_story_no_log.md", "---\nname: No Log\n---\n", + None, ); let output = logs_cmd(tmp.path(), "77").unwrap(); assert!( @@ -221,6 +222,7 @@ mod tests { "2_current", "88_story_has_log.md", "---\nname: Has Log\n---\n", + None, ); // Write a log file in the expected location. let log_dir = tmp diff --git a/server/src/chat/commands/move_story.rs b/server/src/chat/commands/move_story.rs index bfdfb260..6bd3b056 100644 --- a/server/src/chat/commands/move_story.rs +++ b/server/src/chat/commands/move_story.rs @@ -170,7 +170,8 @@ mod tests { tmp.path(), "1_backlog", "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(); @@ -201,7 +202,8 @@ mod tests { tmp.path(), "2_current", "8810_story_case_test.md", - "---\nname: CaseTest\n---\n", + "", + Some("CaseTest"), ); let output = move_cmd_with_root(tmp.path(), "8810 BACKLOG").unwrap(); assert!( @@ -218,6 +220,7 @@ mod tests { "2_current", "5_story_already_current.md", "---\nname: Already Current\n---\n", + None, ); // Moving to the stage it's already in should return a success message. let output = move_cmd_with_root(tmp.path(), "5 current").unwrap(); diff --git a/server/src/chat/commands/show.rs b/server/src/chat/commands/show.rs index 05b6d8b3..e2f3790d 100644 --- a/server/src/chat/commands/show.rs +++ b/server/src/chat/commands/show.rs @@ -209,6 +209,7 @@ mod tests { "1_backlog", "305_story_show_command.md", "---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.", + None, ); let output = show_cmd_with_root(tmp.path(), "305").unwrap(); assert!( @@ -225,6 +226,7 @@ mod tests { "2_current", "42_story_do_something.md", "---\nname: Do something\n---\n\n# Story 42\n\nIn progress.", + None, ); let output = show_cmd_with_root(tmp.path(), "42").unwrap(); assert!( @@ -241,6 +243,7 @@ mod tests { "1_backlog", "7_bug_crash_on_login.md", "---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.", + None, ); let output = show_cmd_with_root(tmp.path(), "7").unwrap(); assert!( diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index 366ca9c2..c7f83ef7 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -630,10 +630,8 @@ fn merge_item_det_merge_running_preferred_over_failure() { crate::db::write_item_with_content( "906_story_det_over_fail", "4_merge", - "---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Det Over Fail\nmerge_failure: \"old failure\"\n---\n", - ), + "---\nname: Det Over Fail\n---\n", + crate::db::ItemMeta::named("Det Over Fail"), ); // Record a running deterministic merge in the CRDT. crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None); diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index 98d2aa1f..6f244173 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -307,7 +307,8 @@ mod tests { tmp.path(), "2_current", "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(); assert!(output.contains("99"), "should show story number: {output}"); @@ -328,7 +329,8 @@ mod tests { tmp.path(), "1_backlog", "9901_story_backlog_item.md", - "---\nname: Backlog Item\n---\n", + "", + Some("Backlog Item"), ); let output = status_triage_cmd(tmp.path(), "9901").unwrap(); assert!( @@ -352,7 +354,8 @@ mod tests { tmp.path(), "3_qa", "9902_story_qa_item.md", - "---\nname: QA Item\n---\n", + "", + Some("QA Item"), ); let output = status_triage_cmd(tmp.path(), "9902").unwrap(); assert!( @@ -373,6 +376,7 @@ mod tests { "2_current", "99_story_criteria_test.md", "---\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(); assert!( @@ -398,6 +402,7 @@ mod tests { "2_current", "55_story_blocked_story.md", "---\nname: Blocked Story\nblocked: true\n---\n", + None, ); let output = status_triage_cmd(tmp.path(), "55").unwrap(); assert!( @@ -413,8 +418,10 @@ mod tests { tmp.path(), "2_current", "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(); assert!( output.contains("coder-1"), @@ -429,8 +436,10 @@ mod tests { tmp.path(), "2_current", "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(); assert!( output.contains("depends_on") || output.contains("#477"), @@ -450,6 +459,7 @@ mod tests { "2_current", "77_story_no_worktree.md", "---\nname: No Worktree\n---\n", + None, ); let output = status_triage_cmd(tmp.path(), "77").unwrap(); // Branch name should still appear diff --git a/server/src/chat/commands/unblock.rs b/server/src/chat/commands/unblock.rs index 00cf0f97..c6d33045 100644 --- a/server/src/chat/commands/unblock.rs +++ b/server/src/chat/commands/unblock.rs @@ -181,6 +181,7 @@ mod tests { "2_current", "42_story_test.md", "---\nname: Test Story\nretry_count: 2\n---\n# Story\n", + None, ); let output = unblock_cmd_with_root(tmp.path(), "42").unwrap(); assert!( @@ -200,6 +201,7 @@ mod tests { "2_blocked", "9903_story_stuck.md", "---\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 // Blocked → Coding transition fires and clears `blocked` properly. @@ -267,7 +269,7 @@ mod tests { story_id, stage, 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 // response can echo it back instead of falling through to the raw id. @@ -312,14 +314,15 @@ mod tests { #[test] fn unblock_command_finds_story_in_any_stage() { 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( tmp.path(), "3_qa", "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(); assert!( @@ -338,6 +341,7 @@ mod tests { "1_backlog", "9902_story_blocked_one.md", "---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n", + None, ); let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap(); diff --git a/server/src/chat/lookup.rs b/server/src/chat/lookup.rs index f6749151..36efdad8 100644 --- a/server/src/chat/lookup.rs +++ b/server/src/chat/lookup.rs @@ -99,6 +99,7 @@ mod tests { "1_backlog", "9970_story_some_feature.md", "---\nname: Some Feature\n---\n\n# Story 9970\n", + None, ); let (story_id, _stage_dir, path, content) = find_story_by_number(tmp.path(), "9970").expect("should find story 9970"); @@ -121,6 +122,7 @@ mod tests { "2_current", "7_bug_crash_on_login.md", "---\nname: Crash on login\n---\n", + None, ); let (story_id, _stage_dir, _, _) = find_story_by_number(tmp.path(), "7").expect("should find bug 7"); @@ -136,6 +138,7 @@ mod tests { "1_backlog", "9971_story_foo.md", "---\nname: Foo\n---\n", + None, ); let result = find_story_by_number(tmp.path(), "99710"); assert!(result.is_none(), "number 99710 should not match story 9971"); @@ -149,6 +152,7 @@ mod tests { "4_merge", "503_story_migration.md", "---\nname: Migration\n---\n", + None, ); let (_, _, path, _) = find_story_by_number(tmp.path(), "503").expect("should find story 503"); diff --git a/server/src/chat/test_helpers.rs b/server/src/chat/test_helpers.rs index 938c212e..8e539d23 100644 --- a/server/src/chat/test_helpers.rs +++ b/server/src/chat/test_helpers.rs @@ -10,15 +10,26 @@ use std::path::Path; /// which still verify filesystem state (e.g. assign tests that check the /// physical file) continue to work. /// -/// Uses `write_item_with_content` to populate both the in-memory content -/// store and the CRDT, matching the production write path. -pub(crate) fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) { +/// Story 929: callers pass the typed `name` explicitly — `content` is now +/// just the markdown body and is no longer parsed for metadata. Other +/// 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); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(filename), content).unwrap(); let story_id = filename.trim_end_matches(".md"); 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); } diff --git a/server/src/chat/transport/matrix/assign.rs b/server/src/chat/transport/matrix/assign.rs index a0ea2bfd..8dae27d6 100644 --- a/server/src/chat/transport/matrix/assign.rs +++ b/server/src/chat/transport/matrix/assign.rs @@ -310,6 +310,7 @@ mod tests { "1_backlog", "9972_story_test.md", "---\nname: Test Feature\n---\n\n# Story 9972\n", + None, ); // Seed CRDT so set_agent can write to the item. crate::crdt_state::write_item( @@ -366,6 +367,7 @@ mod tests { "1_backlog", "9973_story_small.md", "---\nname: Small Story\n---\n", + None, ); crate::crdt_state::write_item( "9973_story_small", @@ -416,6 +418,7 @@ mod tests { "1_backlog", "9974_story_existing.md", "---\nname: Existing\nagent: coder-sonnet\n---\n", + None, ); crate::crdt_state::write_item( "9974_story_existing", @@ -456,6 +459,7 @@ mod tests { "3_qa", "99_story_in_qa.md", "---\nname: In QA\n---\n", + None, ); let agents = std::sync::Arc::new(AgentPool::new_test(3000)); @@ -476,6 +480,7 @@ mod tests { "2_current", "10_story_current.md", "---\nname: Current Story\nagent: coder-sonnet\n---\n", + None, ); let agents = std::sync::Arc::new(AgentPool::new_test(3000)); diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 828a2732..0ba49bdc 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -258,9 +258,7 @@ mod tests { story_id, "1_backlog", "---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n", - crate::db::ItemMeta::from_yaml( - "---\nname: CRDT Tombstone Check\n---\n\n# Story 9977\n", - ), + crate::db::ItemMeta::named("CRDT Tombstone Check"), ); let tmp = tempfile::tempdir().unwrap(); @@ -291,7 +289,7 @@ mod tests { "9975_story_some_feature", "1_backlog", "---\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)); diff --git a/server/src/chat/transport/matrix/start.rs b/server/src/chat/transport/matrix/start.rs index e8d59b57..068a2041 100644 --- a/server/src/chat/transport/matrix/start.rs +++ b/server/src/chat/transport/matrix/start.rs @@ -272,7 +272,7 @@ mod tests { "9976_story_test", "1_backlog", "---\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)); diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index 0934425d..b95e39ba 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -54,7 +54,7 @@ pub use types::{ pub use write::{ bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, set_agent, set_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)] diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index 3d998664..a5aa32ad 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -128,6 +128,29 @@ pub fn set_mergemaster_attempted(story_id: &str, value: bool) -> bool { 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. /// /// `Some(name)` writes the agent name into the CRDT register. diff --git a/server/src/crdt_state/write/mod.rs b/server/src/crdt_state/write/mod.rs index 703b309e..98a1122d 100644 --- a/server/src/crdt_state/write/mod.rs +++ b/server/src/crdt_state/write/mod.rs @@ -11,6 +11,6 @@ mod tests; pub use item::{ 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}; diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 4aec9895..50a15fe8 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -19,9 +19,6 @@ pub mod content_store; pub mod ops; /// Background shadow-write task — persists pipeline items to SQLite asynchronously. 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 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)] mod tests { use super::*; - use crate::db::yaml_legacy::parse_front_matter; use std::fs; /// 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.1.as_deref(), Some("Shadow Test")); assert_eq!(row.2, "2_current"); - - // Verify metadata was parsed correctly from the story file. - let (name, _agent, retry_count, _blocked, _depends_on) = - match std::fs::read_to_string(&story_path) { - Ok(contents) => match parse_front_matter(&contents) { - 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)); + // The shadow row's name + retry_count came through from the INSERT + // params above; that's what the test exercises. Story 929 dropped + // the redundant "re-parse the YAML body to double-check" step that + // used to live here. + let _ = story_path; } #[tokio::test] diff --git a/server/src/db/ops.rs b/server/src/db/ops.rs index 6eb2aea0..f1de730a 100644 --- a/server/src/db/ops.rs +++ b/server/src/db/ops.rs @@ -7,14 +7,12 @@ use super::content_store::{ all_content_ids, delete_content, ensure_content_store, read_content, write_content, }; use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg}; -use super::yaml_legacy::parse_front_matter; /// Typed metadata for a pipeline item write. /// -/// Replaces the prior YAML-parsing write path (story 864): callers now pass -/// metadata explicitly instead of round-tripping it through a serialized -/// front-matter blob. Every field is `Option`-typed; `None` means -/// "leave unchanged" on update, "use the default" on insert. +/// Story 929: callers pass metadata explicitly — no YAML parsing. Every +/// field is `Option`-typed; `None` means "leave unchanged" on update, +/// "use the default" on insert. #[derive(Default, Clone, Debug)] pub struct ItemMeta { pub name: Option, @@ -33,26 +31,6 @@ impl ItemMeta { ..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). diff --git a/server/src/db/yaml_legacy.rs b/server/src/db/yaml_legacy.rs deleted file mode 100644 index f96715db..00000000 --- a/server/src/db/yaml_legacy.rs +++ /dev/null @@ -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, - pub coverage_baseline: Option, - pub merge_failure: Option, - pub agent: Option, - pub review_hold: Option, - pub qa: Option, - pub retry_count: Option, - pub blocked: Option, - pub depends_on: Option>, - pub frozen: Option, - pub resume_to_stage: Option, - pub run_tests_passed: Option, - #[serde(rename = "type")] - pub item_type: Option, - pub mergemaster_attempted: Option, - pub epic: Option, -} - -/// Parsed metadata view returned by [`parse_front_matter`]. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct StoryMetadata { - pub name: Option, - pub coverage_baseline: Option, - pub merge_failure: Option, - pub agent: Option, - pub review_hold: Option, - pub qa: Option, - pub retry_count: Option, - pub blocked: Option, - pub depends_on: Option>, - pub frozen: Option, - pub resume_to_stage: Option, - pub run_tests_passed: Option, - pub item_type: Option, - pub mergemaster_attempted: Option, - pub epic: Option, -} - -/// 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 { - 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 = 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")); - } -} diff --git a/server/src/http/agents/tests.rs b/server/src/http/agents/tests.rs index 30fe4f7f..6e8017c5 100644 --- a/server/src/http/agents/tests.rs +++ b/server/src/http/agents/tests.rs @@ -353,7 +353,7 @@ async fn get_work_item_content_falls_back_to_crdt_when_no_file() { "44_story_crdt_only", "1_backlog", "---\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 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", "2_current", "---\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 api = AgentsApi { ctx: Arc::new(ctx) }; diff --git a/server/src/http/mcp/agent_tools/lifecycle.rs b/server/src/http/mcp/agent_tools/lifecycle.rs index 2e220b8f..5b1e627e 100644 --- a/server/src/http/mcp/agent_tools/lifecycle.rs +++ b/server/src/http/mcp/agent_tools/lifecycle.rs @@ -3,7 +3,6 @@ use serde_json::{Value, json}; use crate::http::context::AppContext; -use crate::slog_warn; use super::worktree::get_worktree_commits; @@ -32,16 +31,10 @@ pub(crate) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result Result Option { - 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 { let story_id = args .get("story_id") @@ -334,25 +312,4 @@ stage = "coder" // completion key present (null for agents that didn't call report_completion) 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()); - } } diff --git a/server/src/http/mcp/diagnostics/permission.rs b/server/src/http/mcp/diagnostics/permission.rs index a9aad1b8..0560ea79 100644 --- a/server/src/http/mcp/diagnostics/permission.rs +++ b/server/src/http/mcp/diagnostics/permission.rs @@ -453,7 +453,7 @@ mod tests { "5_story_test", "1_backlog", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::default(), ); let ctx = test_ctx(root); @@ -485,7 +485,7 @@ mod tests { "6_story_back", "2_current", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::default(), ); let ctx = test_ctx(root); @@ -517,7 +517,7 @@ mod tests { "9907_story_idem", "2_current", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::default(), ); let ctx = test_ctx(root); diff --git a/server/src/http/mcp/status_tools.rs b/server/src/http/mcp/status_tools.rs index 35d942db..07722072 100644 --- a/server/src/http/mcp/status_tools.rs +++ b/server/src/http/mcp/status_tools.rs @@ -362,13 +362,16 @@ mod tests { crate::crdt_state::init_for_test(); 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( "9887_story_blocked_test", "2_current", 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 result = tool_status(&json!({"story_id": "9887_story_blocked_test"}), &ctx) @@ -389,13 +392,14 @@ mod tests { let tmp = tempdir().unwrap(); 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( "9886_story_status_test", "2_current", 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 result = tool_status(&json!({"story_id": "9886_story_status_test"}), &ctx) diff --git a/server/src/http/mcp/story_tools/bug.rs b/server/src/http/mcp/story_tools/bug.rs index 6e4764ce..00fa4e74 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -287,18 +287,14 @@ mod tests { crate::db::write_item_with_content( "9902_bug_crash", "1_backlog", - "---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", - crate::db::ItemMeta::from_yaml( - "---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", - ), + "# Bug 9902: App Crash\n", + crate::db::ItemMeta::named("App Crash"), ); crate::db::write_item_with_content( "9903_bug_typo", "1_backlog", - "---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", - crate::db::ItemMeta::from_yaml( - "---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", - ), + "# Bug 9903: Typo in Header\n", + crate::db::ItemMeta::named("Typo in Header"), ); let ctx = test_ctx(tmp.path()); @@ -446,7 +442,7 @@ mod tests { "9901_bug_crash", "1_backlog", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::default(), ); // Stage the file so it's tracked std::process::Command::new("git") diff --git a/server/src/http/mcp/story_tools/criteria.rs b/server/src/http/mcp/story_tools/criteria.rs index 5410b141..d0c8bf14 100644 --- a/server/src/http/mcp/story_tools/criteria.rs +++ b/server/src/http/mcp/story_tools/criteria.rs @@ -421,9 +421,7 @@ mod tests { "9901_test", "2_current", "---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", - ), + crate::db::ItemMeta::named("Test"), ); let ctx = test_ctx(tmp.path()); @@ -517,7 +515,7 @@ mod tests { "9906_story_persist", "2_current", "---\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()); @@ -555,7 +553,7 @@ mod tests { "9905_story_file_only", "2_current", story_content, - crate::db::ItemMeta::from_yaml(story_content), + crate::db::ItemMeta::default(), ); let ctx = test_ctx(tmp.path()); @@ -627,9 +625,7 @@ mod tests { "9997_empty_branch", "2_current", "---\nname: Empty Branch Test\n---\n## AC\n- [ ] Implement the feature\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Empty Branch Test\n---\n## AC\n- [ ] Implement the feature\n", - ), + crate::db::ItemMeta::named("Empty Branch Test"), ); let ctx = test_ctx(tmp.path()); @@ -690,9 +686,7 @@ mod tests { "9904_test", "2_current", "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", - ), + crate::db::ItemMeta::named("Test"), ); let ctx = test_ctx(tmp.path()); @@ -744,9 +738,7 @@ mod tests { "9905_test", "2_current", "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n", - ), + crate::db::ItemMeta::named("Test"), ); let ctx = test_ctx(tmp.path()); @@ -768,9 +760,7 @@ mod tests { "9906_test", "2_current", "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n", - ), + crate::db::ItemMeta::named("Test"), ); let ctx = test_ctx(tmp.path()); diff --git a/server/src/http/mcp/story_tools/story/delete.rs b/server/src/http/mcp/story_tools/story/delete.rs index ad0b4cb1..efe213d7 100644 --- a/server/src/http/mcp/story_tools/story/delete.rs +++ b/server/src/http/mcp/story_tools/story/delete.rs @@ -230,7 +230,7 @@ mod tests { "51_story_no_branch", "2_current", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::default(), ); let ctx = test_ctx(tmp.path()); diff --git a/server/src/http/mcp/story_tools/story/freeze.rs b/server/src/http/mcp/story_tools/story/freeze.rs index 29cb9acc..7559c34a 100644 --- a/server/src/http/mcp/story_tools/story/freeze.rs +++ b/server/src/http/mcp/story_tools/story/freeze.rs @@ -60,7 +60,7 @@ mod tests { story_id, "2_current", "---\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(); @@ -89,7 +89,7 @@ mod tests { story_id, "2_current", "---\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(); diff --git a/server/src/http/mcp/story_tools/story/query.rs b/server/src/http/mcp/story_tools/story/query.rs index bab13788..c32403e9 100644 --- a/server/src/http/mcp/story_tools/story/query.rs +++ b/server/src/http/mcp/story_tools/story/query.rs @@ -154,7 +154,10 @@ mod tests { id, stage, &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", "2_current", "---\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()); @@ -225,7 +228,7 @@ mod tests { "9907_test", "2_current", "---\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()); @@ -247,7 +250,7 @@ mod tests { "9908_test", "2_current", "## 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()); diff --git a/server/src/http/mcp/story_tools/story/update.rs b/server/src/http/mcp/story_tools/story/update.rs index d17db348..90e5e3d3 100644 --- a/server/src/http/mcp/story_tools/story/update.rs +++ b/server/src/http/mcp/story_tools/story/update.rs @@ -4,7 +4,6 @@ use crate::http::context::AppContext; use crate::http::workflow::update_story_in_file; use crate::slog_warn; use serde_json::Value; -use std::collections::HashMap; pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { let story_id = args @@ -14,88 +13,110 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result = HashMap::new(); + // Explicit top-level args map onto typed CRDT registers directly (story 929: + // no YAML front-matter writes). The `front_matter` object is the legacy + // escape hatch; every known key is recognised and routed below, and any + // unknown key is rejected loudly rather than silently flushed to disk. 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()) { - 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()) { - 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()) { - for (k, v) in obj { - front_matter.insert(k.clone(), v.clone()); + for (key, value) in obj { + 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 = 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 = 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()?; - // Only call update_story_in_file when there is something left to write. - if user_story.is_some() || description.is_some() || front_matter_opt.is_some() { - update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?; - } - - // 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)); + // Only call update_story_in_file when there is body content to update. + if user_story.is_some() || description.is_some() { + update_story_in_file(&root, story_id, user_story, description)?; } // 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= 4); } @@ -75,7 +74,7 @@ fn next_item_number_scans_archived_too() { "5_bug_old", "5_done", "---\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); } @@ -98,14 +97,14 @@ fn list_bug_files_excludes_archive_subdir() { "7001_bug_open", "1_backlog", "---\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). crate::db::write_item_with_content( "7002_bug_closed", "5_done", "---\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(); @@ -125,19 +124,19 @@ fn list_bug_files_sorted_by_id() { "7013_bug_third", "1_backlog", "---\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( "7011_bug_first", "1_backlog", "---\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( "7012_bug_second", "1_backlog", "---\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(); @@ -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:?}"); let spike_id = result.unwrap(); - let contents = crate::db::read_content(&spike_id) - .or_else(|| { - 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)); + let view = crate::crdt_state::read_item(&spike_id).expect("CRDT entry should exist"); + assert_eq!(view.name(), Some(name)); } #[test] @@ -369,7 +361,7 @@ fn create_spike_file_increments_from_existing_items() { "7050_story_existing", "1_backlog", "---\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(); @@ -387,7 +379,8 @@ fn create_spike_file_increments_from_existing_items() { // ── Bug 640: create_bug_file / create_refactor_file depends_on tests ──────── #[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(); setup_git_repo(tmp.path()); @@ -403,26 +396,8 @@ fn create_bug_file_with_depends_on_writes_front_matter_array() { ) .unwrap(); - let contents = crate::db::read_content(&bug_id) - .or_else(|| { - 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])); + let view = crate::crdt_state::read_item(&bug_id).expect("CRDT entry should exist"); + assert_eq!(view.depends_on(), &[42, 43]); } #[test] @@ -458,29 +433,16 @@ fn create_bug_file_without_depends_on_omits_field() { } #[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(); setup_git_repo(tmp.path()); let refactor_id = create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap(); - let contents = crate::db::read_content(&refactor_id) - .or_else(|| { - 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])); + let view = crate::crdt_state::read_item(&refactor_id).expect("CRDT entry should exist"); + assert_eq!(view.depends_on(), &[99]); } #[test] diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs index 29a45076..c9c8796a 100644 --- a/server/src/http/workflow/mod.rs +++ b/server/src/http/workflow/mod.rs @@ -16,10 +16,7 @@ pub use story_ops::{ add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file, remove_criterion_from_file, update_story_in_file, }; -pub use test_results::{ - read_test_results_from_story_file, write_coverage_baseline_to_story_file, - write_test_results_to_story_file, -}; +pub use test_results::{read_test_results_from_story_file, write_test_results_to_story_file}; pub(crate) use utils::{ create_section_content, next_item_number, read_story_content, replace_or_append_section, diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index 4a2101e3..aaa94e82 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -308,7 +308,10 @@ mod tests { id, stage, &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", "2_current", "---\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); @@ -389,7 +392,7 @@ mod tests { "9861_story_done", "2_current", "---\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); @@ -422,7 +425,7 @@ mod tests { "9862_story_pending", "2_current", "---\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); @@ -448,20 +451,20 @@ mod tests { #[test] fn pipeline_state_includes_depends_on() { + crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "9863_story_dependent", "1_backlog", - "---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n", - ), + "# Dependent Story\n", + crate::db::ItemMeta::named("Dependent Story"), ); + crate::crdt_state::set_depends_on("9863_story_dependent", &[10, 11]); crate::db::write_item_with_content( "9864_story_independent", "1_backlog", - "---\nname: Independent Story\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Independent Story\n---\n"), + "# Independent Story\n", + crate::db::ItemMeta::named("Independent Story"), ); let tmp = tempfile::tempdir().unwrap(); @@ -490,13 +493,13 @@ mod tests { "9870_story_view_upcoming", "1_backlog", "---\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( "9871_story_worktree", "1_backlog", "---\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(); @@ -523,7 +526,7 @@ mod tests { "9872_story_example", "1_backlog", "---\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(); @@ -539,13 +542,13 @@ mod tests { "9873_story_todos", "2_current", "---\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( "9874_story_front_matter", "1_backlog", "---\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(); @@ -591,7 +594,7 @@ mod tests { "9876_story_no_name", "2_current", "---\n---\n# Story\n", - crate::db::ItemMeta::from_yaml("---\n---\n# Story\n"), + crate::db::ItemMeta::default(), ); let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/http/workflow/story_ops/create.rs b/server/src/http/workflow/story_ops/create.rs index f2195938..68421e38 100644 --- a/server/src/http/workflow/story_ops/create.rs +++ b/server/src/http/workflow/story_ops/create.rs @@ -66,7 +66,7 @@ pub fn create_story_file( content.push_str("- TBD\n"); // 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. crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[])); @@ -83,7 +83,6 @@ pub fn create_story_file( #[cfg(test)] mod tests { use super::*; - use crate::db::yaml_legacy::parse_front_matter; use std::fs; #[allow(dead_code)] @@ -146,7 +145,7 @@ mod tests { "36_story_existing", "1_backlog", "---\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(); @@ -184,29 +183,24 @@ mod tests { } #[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 name = "Server-owned agent completion: remove report_completion dependency"; let result = create_story_file(tmp.path(), name, None, None, None, None, false); assert!(result.is_ok(), "create_story_file failed: {result:?}"); let story_id = result.unwrap(); - // Read from content store or filesystem. - let content = crate::db::read_content(&story_id) - .or_else(|| { - 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)); + let view = + crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create"); + assert_eq!(view.name(), Some(name)); } // ── check_criterion_in_file tests ───────────────────────────────────────── #[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 story_id = create_story_file( tmp.path(), @@ -219,24 +213,9 @@ mod tests { ) .unwrap(); - let contents = crate::db::read_content(&story_id) - .or_else(|| { - 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"); - - 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])); + let view = + crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create"); + assert_eq!(view.depends_on(), &[489]); } // ── Story 730: numeric-only story IDs ───────────────────────────────────── @@ -258,19 +237,18 @@ mod tests { } #[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(); let tmp = tempfile::tempdir().unwrap(); let story_id = create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false) .unwrap(); - let content = crate::db::read_content(&story_id).expect("content must exist"); - let meta = crate::db::yaml_legacy::parse_front_matter(&content) - .expect("front matter should be valid"); + let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist"); assert_eq!( - meta.item_type.as_deref(), + view.item_type(), Some("story"), - "front matter must contain type: story" + "CRDT register must be set to story" ); } diff --git a/server/src/http/workflow/story_ops/criterion.rs b/server/src/http/workflow/story_ops/criterion.rs index d0c6b787..aadf27d0 100644 --- a/server/src/http/workflow/story_ops/criterion.rs +++ b/server/src/http/workflow/story_ops/criterion.rs @@ -52,7 +52,7 @@ pub fn check_criterion_in_file( // Write back to content store. 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(()) } @@ -99,7 +99,7 @@ pub fn remove_criterion_from_file( } 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(()) } @@ -158,7 +158,7 @@ pub fn edit_criterion_in_file( } 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(()) } @@ -219,7 +219,7 @@ pub fn add_criterion_to_file( // Write back to content store. 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(()) } @@ -234,10 +234,7 @@ pub fn add_criterion_to_file( /// - String → quoted unless it looks like a bool, integer, or inline sequence #[cfg(test)] mod tests { - use super::super::update_story_in_file; use super::*; - use serde_json::Value; - use std::collections::HashMap; use std::fs; #[allow(dead_code)] @@ -437,11 +434,8 @@ mod tests { ## Recommendation\n\n- TBD\n"; setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content); - // Convert spike to story by updating the type field. - let mut fields = HashMap::new(); - 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"); + // Convert spike to story by updating the typed item_type CRDT register. + crate::crdt_state::set_item_type("100_spike_my_spike", Some("story")); // Add three acceptance criteria. add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion") diff --git a/server/src/http/workflow/story_ops/update.rs b/server/src/http/workflow/story_ops/update.rs index 9a0f48b8..2783c080 100644 --- a/server/src/http/workflow/story_ops/update.rs +++ b/server/src/http/workflow/story_ops/update.rs @@ -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; #[allow(unused_imports)] @@ -10,48 +14,6 @@ use super::super::{ 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 = 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::().is_ok() => s.to_string(), - s if s.parse::().is_ok() => s.to_string(), - // YAML inline sequences like [490] or [490, 491] — write unquoted so - // serde_yaml can deserialise them as Vec. - 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. /// /// At least one of `user_story` or `description` must be provided. @@ -61,32 +23,13 @@ pub fn update_story_in_file( story_id: &str, user_story: Option<&str>, description: Option<&str>, - front_matter: Option<&HashMap>, ) -> 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() && !has_front_matter_updates { - return Err( - "At least one of 'user_story', 'description', or 'front_matter' must be provided." - .to_string(), - ); + if user_story.is_none() && description.is_none() { + return Err("At least one of 'user_story' or 'description' must be provided.".to_string()); } 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 { contents = match replace_section_content(&contents, "User Story", us) { Ok(updated) => updated, @@ -106,7 +49,7 @@ pub fn update_story_in_file( // Write back to content store. 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(()) } @@ -114,98 +57,27 @@ pub fn update_story_in_file( #[cfg(test)] mod tests { use super::*; - use crate::db::yaml_legacy::parse_front_matter; use std::fs; - #[allow(dead_code)] - fn setup_git_repo(root: &std::path::Path) { - std::process::Command::new("git") - .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)] + /// Helper: write a story to both the in-memory content store and the + /// `.huskies/work/2_current/` directory so `read_story_content` picks it up + /// regardless of which read path is used. fn setup_story_in_fs(root: &std::path::Path, story_id: &str, content: &str) { let current = root.join(".huskies/work/2_current"); fs::create_dir_all(¤t).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::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] fn update_story_replaces_user_story_section() { 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); - update_story_in_file( - tmp.path(), - "20_test", - Some("New user story text"), - None, - None, - ) - .unwrap(); + update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None).unwrap(); let result = read_story_content(tmp.path(), "20_test").unwrap(); assert!( @@ -222,10 +94,11 @@ mod tests { #[test] fn update_story_replaces_description_section() { 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); - 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(); assert!( @@ -241,9 +114,9 @@ mod tests { #[test] fn update_story_no_args_returns_error() { 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.unwrap_err().contains("At least one")); } @@ -251,12 +124,10 @@ mod tests { #[test] fn update_story_creates_user_story_section_if_missing() { let tmp = tempfile::tempdir().unwrap(); - // Story with no ## User Story section but has ## Acceptance Criteria. - let content = "---\nname: T\n---\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + let content = "# Story\n\n## Acceptance Criteria\n\n- [ ] AC\n"; setup_story_in_fs(tmp.path(), "23_test", content); - let result = - update_story_in_file(tmp.path(), "23_test", Some("New user story"), None, None); + let result = update_story_in_file(tmp.path(), "23_test", Some("New user story"), None); assert!( result.is_ok(), "should succeed when section is missing: {result:?}" @@ -268,7 +139,6 @@ mod tests { "section should be created" ); 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_ac = updated.find("## Acceptance Criteria").unwrap(); assert!( @@ -280,17 +150,12 @@ mod tests { #[test] fn update_story_creates_description_section_if_missing() { let tmp = tempfile::tempdir().unwrap(); - // Story with no ## Description section but has ## Acceptance Criteria. - let content = "---\nname: T\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + let content = + "# 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); - let result = update_story_in_file( - tmp.path(), - "32_test", - None, - Some("New description text"), - None, - ); + let result = + update_story_in_file(tmp.path(), "32_test", None, Some("New description text")); assert!( result.is_ok(), "should succeed when section is missing: {result:?}" @@ -305,7 +170,6 @@ mod tests { updated.contains("New description text"), "text should be present" ); - // Section should appear before Acceptance Criteria. let pos_desc = updated.find("## Description").unwrap(); let pos_ac = updated.find("## Acceptance Criteria").unwrap(); assert!( @@ -317,17 +181,11 @@ mod tests { #[test] fn update_story_creates_description_section_no_ac_section() { let tmp = tempfile::tempdir().unwrap(); - // Story with no ## Description and no ## Acceptance Criteria. - let content = "---\nname: T\n---\n\nSome content here.\n"; + let content = "# Story\n\nSome content here.\n"; setup_story_in_fs(tmp.path(), "33_test", content); - let result = update_story_in_file( - tmp.path(), - "33_test", - None, - Some("Appended description"), - None, - ); + let result = + update_story_in_file(tmp.path(), "33_test", None, Some("Appended description")); assert!( result.is_ok(), "should succeed even with no Acceptance Criteria: {result:?}" @@ -343,286 +201,4 @@ mod tests { "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])); - } } diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs index 2fc88b7d..ee221d73 100644 --- a/server/src/http/workflow/test_results.rs +++ b/server/src/http/workflow/test_results.rs @@ -1,5 +1,4 @@ //! 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 std::path::Path; @@ -27,7 +26,7 @@ pub fn write_test_results_to_story_file( // Write back to content store and CRDT. 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(()) } @@ -43,31 +42,6 @@ pub fn read_test_results_from_story_file( 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. fn build_test_results_section(json: &str, results: &StoryTestResults) -> String { let mut s = String::from("## Test Results\n\n"); @@ -176,7 +150,7 @@ mod tests { "8001_story_test", "2_current", "---\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(); @@ -202,9 +176,7 @@ mod tests { "8002_story_check", "2_current", "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n", - ), + crate::db::ItemMeta::named("Check"), ); let results = make_results(); @@ -227,9 +199,7 @@ mod tests { "8003_story_overwrite", "2_current", "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", - ), + crate::db::ItemMeta::named("Overwrite"), ); let results = make_results(); @@ -249,7 +219,7 @@ mod tests { "8004_story_empty", "2_current", "---\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"); @@ -271,7 +241,7 @@ mod tests { "8005_story_qa", "3_qa", "---\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 { @@ -287,31 +257,4 @@ mod tests { let read_back = read_test_results_from_story_file(tmp.path(), "8005_story_qa").unwrap(); 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()); - } } diff --git a/server/src/http/workflow/utils.rs b/server/src/http/workflow/utils.rs index 6b5aafb6..ef5b9636 100644 --- a/server/src/http/workflow/utils.rs +++ b/server/src/http/workflow/utils.rs @@ -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. +/// +/// 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( _project_root: &Path, story_id: &str, stage: &str, content: &str, + name: Option<&str>, ) { - let mut meta = crate::db::ItemMeta::from_yaml(content); - // CRDT is the single source of truth for depends_on. Never overwrite the - // register from YAML here — the typed setter (crdt_state::set_depends_on) - // 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; + let meta = crate::db::ItemMeta { + name: name.map(str::to_string), + ..Default::default() + }; crate::db::write_item_with_content(story_id, stage, content, meta); } @@ -273,7 +277,7 @@ mod tests { "9877_story_foo", "1_backlog", "---\nname: Foo\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), + crate::db::ItemMeta::named("Foo"), ); let tmp = tempfile::tempdir().unwrap(); assert!(next_item_number(tmp.path()).unwrap() >= 9878); diff --git a/server/src/io/watcher/tests.rs b/server/src/io/watcher/tests.rs index de6f86d7..518d19d8 100644 --- a/server/src/io/watcher/tests.rs +++ b/server/src/io/watcher/tests.rs @@ -81,7 +81,7 @@ fn sweep_moves_old_items_to_archived() { "9880_story_sweep_old", "5_done", "---\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. @@ -105,7 +105,7 @@ fn sweep_keeps_recent_items_in_done() { "9881_story_sweep_new", "5_done", "---\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. @@ -128,7 +128,7 @@ fn sweep_respects_custom_retention() { "9882_story_sweep_custom", "5_done", "---\nname: custom\n---\n", - crate::db::ItemMeta::from_yaml("---\nname: custom\n---\n"), + crate::db::ItemMeta::named("custom"), ); // With ZERO retention, sweep should promote. diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index d434c046..8ef27a8f 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -658,7 +658,7 @@ fn regression_freeze_unfreeze_restores_crdt_stage() { story_id, "2_current", content, - crate::db::ItemMeta::from_yaml(content), + crate::db::ItemMeta::named("Freeze Regression"), ); // Confirm starting stage. @@ -711,7 +711,7 @@ fn merge_failure_transition_emits_event_with_full_reason() { story_id, "4_merge", "---\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"; @@ -783,7 +783,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification() story_id, "4_merge_failure", "---\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. @@ -856,7 +856,7 @@ fn merge_failure_accept_moves_to_done_via_crdt() { story_id, "4_merge_failure", "---\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) diff --git a/server/src/service/notifications/io/tests_notifications.rs b/server/src/service/notifications/io/tests_notifications.rs index 2fbb2263..9df5a620 100644 --- a/server/src/service/notifications/io/tests_notifications.rs +++ b/server/src/service/notifications/io/tests_notifications.rs @@ -18,7 +18,7 @@ async fn rate_limit_warning_sends_notification_with_agent_and_story() { "365_story_rate_limit", "2_current", "---\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::(16); @@ -145,7 +145,7 @@ async fn story_blocked_sends_notification_with_reason() { "425_story_blocking_test", "2_current", "---\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::(16); diff --git a/server/src/service/notifications/io/tests_stage.rs b/server/src/service/notifications/io/tests_stage.rs index 3380cc63..395903c1 100644 --- a/server/src/service/notifications/io/tests_stage.rs +++ b/server/src/service/notifications/io/tests_stage.rs @@ -21,7 +21,7 @@ async fn stage_notification_uses_dynamic_room_ids() { "10_story_foo", "3_qa", "---\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::(16); @@ -111,7 +111,7 @@ fn read_story_name_reads_from_front_matter() { "9942_story_my_feature", "2_current", "---\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(); @@ -134,7 +134,7 @@ fn read_story_name_returns_none_for_missing_name_field() { "9943_story_no_name", "2_current", "---\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(); diff --git a/server/src/service/work_item/delete.rs b/server/src/service/work_item/delete.rs index 5755fffb..d3241866 100644 --- a/server/src/service/work_item/delete.rs +++ b/server/src/service/work_item/delete.rs @@ -214,7 +214,7 @@ mod tests { story_id, "1_backlog", "---\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(); diff --git a/server/src/service/work_item/freeze.rs b/server/src/service/work_item/freeze.rs index 2c257cfd..440158c2 100644 --- a/server/src/service/work_item/freeze.rs +++ b/server/src/service/work_item/freeze.rs @@ -79,7 +79,7 @@ mod tests { story_id, "2_current", "---\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); @@ -106,7 +106,7 @@ mod tests { story_id, "2_current", "---\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"); @@ -126,7 +126,7 @@ mod tests { story_id, "2_current", "---\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"); @@ -155,7 +155,7 @@ mod tests { story_id, "2_current", "---\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); @@ -179,7 +179,7 @@ mod tests { story_a, "2_current", "---\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. @@ -188,7 +188,7 @@ mod tests { story_b, "2_current", "---\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().