From 61cf7684de2cc356aad74e588b8000f1188548ab Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 30 Apr 2026 22:23:21 +0000 Subject: [PATCH] huskies: merge 864 --- server/src/agents/lifecycle.rs | 3 + .../agents/pool/auto_assign/auto_assign.rs | 43 ++++++++- server/src/agents/pool/auto_assign/merge.rs | 7 +- server/src/agents/pool/auto_assign/scan.rs | 22 ++++- .../agents/pool/auto_assign/story_checks.rs | 3 + .../agents/pool/pipeline/advance/helpers.rs | 7 +- .../src/agents/pool/pipeline/advance/tests.rs | 7 +- .../pool/pipeline/advance/tests_regression.rs | 15 +++- .../src/agents/pool/pipeline/merge/tests.rs | 3 + server/src/agents/pool/start/spawn.rs | 21 ++++- .../agents/pool/start/tests_concurrency.rs | 21 ++++- server/src/agents/pool/stop.rs | 7 +- server/src/agents/pool/worktree.rs | 21 ++++- server/src/chat/commands/status/tests.rs | 27 +++++- server/src/chat/commands/unblock.rs | 7 +- server/src/chat/test_helpers.rs | 3 +- server/src/chat/transport/matrix/delete.rs | 4 + server/src/chat/transport/matrix/start.rs | 1 + server/src/db/mod.rs | 61 ++++++++++++- server/src/db/ops.rs | 88 ++++++++++++++----- server/src/http/agents/tests.rs | 2 + server/src/http/mcp/diagnostics/permission.rs | 21 ++++- server/src/http/mcp/status_tools.rs | 14 ++- server/src/http/mcp/story_tools/bug.rs | 13 ++- server/src/http/mcp/story_tools/criteria.rs | 23 ++++- server/src/http/mcp/story_tools/epic.rs | 9 ++ .../src/http/mcp/story_tools/story/delete.rs | 7 +- .../src/http/mcp/story_tools/story/freeze.rs | 2 + .../src/http/mcp/story_tools/story/query.rs | 16 +++- .../src/http/mcp/story_tools/story/update.rs | 7 +- server/src/http/workflow/bug_ops/tests.rs | 27 +++++- server/src/http/workflow/pipeline.rs | 27 +++++- server/src/http/workflow/story_ops/create.rs | 1 + server/src/http/workflow/test_results.rs | 10 +++ server/src/http/workflow/utils.rs | 14 ++- server/src/io/watcher/tests.rs | 15 +++- server/src/pipeline_state/tests.rs | 8 +- .../notifications/io/tests_notifications.rs | 2 + .../service/notifications/io/tests_stage.rs | 9 +- server/src/service/work_item/delete.rs | 1 + server/src/service/work_item/freeze.rs | 12 ++- 41 files changed, 540 insertions(+), 71 deletions(-) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 17541240..337e39e6 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -432,6 +432,7 @@ mod tests { "99950_story_lifecycle", "1_backlog", "---\nname: Lifecycle Test\n---\n# Story\n", + crate::db::ItemMeta::named("Lifecycle Test"), ); move_story_to_current("99950_story_lifecycle").unwrap(); @@ -462,6 +463,7 @@ mod tests { "99951_story_crdt_only", "2_current", "---\nname: CRDT Only Test\n---\n# Story\n", + crate::db::ItemMeta::named("CRDT Only Test"), ); // No filesystem path is involved — lifecycle functions no longer @@ -529,6 +531,7 @@ mod tests { "99866_story_block_test", "2_current", "---\nname: Block Round Trip\n---\n# Story\n", + crate::db::ItemMeta::named("Block Round Trip"), ); // Verify starting state is Coding. diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index a25378af..ae977b2d 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -57,7 +57,12 @@ mod tests { .unwrap(); // Place the story in 2_current/ via CRDT (the only source of truth). crate::db::ensure_content_store(); - crate::db::write_item_with_content("story-3", "2_current", "---\nname: Story 3\n---\n"); + crate::db::write_item_with_content( + "story-3", + "2_current", + "---\nname: Story 3\n---\n", + crate::db::ItemMeta::named("Story 3"), + ); let pool = AgentPool::new_test(3001); // No agents are running — coder-1 is free. @@ -139,6 +144,11 @@ mod tests { "9930_story_qa1", "3_qa", "---\nname: QA Story\nagent: coder-1\n---\n", + crate::db::ItemMeta { + name: Some("QA Story".into()), + agent: Some("coder-1".into()), + ..Default::default() + }, ); let pool = AgentPool::new_test(3001); @@ -188,6 +198,11 @@ mod tests { "story-pref", "2_current", "---\nname: Coder Story\nagent: coder-1\n---\n", + crate::db::ItemMeta { + name: Some("Coder Story".into()), + agent: Some("coder-1".into()), + ..Default::default() + }, ); let pool = AgentPool::new_test(3001); @@ -235,6 +250,11 @@ mod tests { "9931_story_noqa", "3_qa", "---\nname: QA Story No Agent\nagent: coder-1\n---\n", + crate::db::ItemMeta { + name: Some("QA Story No Agent".into()), + agent: Some("coder-1".into()), + ..Default::default() + }, ); let pool = AgentPool::new_test(3001); @@ -274,6 +294,7 @@ mod tests { "9932_story_waiting", "2_current", "---\nname: Waiting\ndepends_on: [9999]\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Waiting\ndepends_on: [9999]\n---\n"), ); let pool = AgentPool::new_test(3001); @@ -307,12 +328,18 @@ mod tests { // Seed stories via CRDT (the only source of truth). crate::db::ensure_content_store(); // Dep 999 is now done. - crate::db::write_item_with_content("999_story_dep", "5_done", "---\nname: Dep\n---\n"); + crate::db::write_item_with_content( + "999_story_dep", + "5_done", + "---\nname: Dep\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Dep\n---\n"), + ); // 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"), ); let pool = AgentPool::new_test(3001); @@ -496,6 +523,9 @@ mod tests { "9860_story_conflict", "4_merge", "---\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", + ), ); let pool = AgentPool::new_test(3001); @@ -530,6 +560,9 @@ mod tests { "9861_story_nothing", "4_merge", "---\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", + ), ); let pool = AgentPool::new_test(3001); @@ -565,6 +598,9 @@ mod tests { "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", + ), ); let pool = AgentPool::new_test(3001); @@ -599,6 +635,9 @@ mod tests { "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", + ), ); let pool = AgentPool::new_test(3001); diff --git a/server/src/agents/pool/auto_assign/merge.rs b/server/src/agents/pool/auto_assign/merge.rs index 9f508437..f2bf74a1 100644 --- a/server/src/agents/pool/auto_assign/merge.rs +++ b/server/src/agents/pool/auto_assign/merge.rs @@ -83,7 +83,12 @@ impl AgentPool { &contents, ); crate::db::write_content(story_id, &updated); - crate::db::write_item_with_content(story_id, "4_merge", &updated); + crate::db::write_item_with_content( + story_id, + "4_merge", + &updated, + crate::db::ItemMeta::from_yaml(&updated), + ); } crate::crdt_state::set_mergemaster_attempted(story_id, true); if let Err(e) = self diff --git a/server/src/agents/pool/auto_assign/scan.rs b/server/src/agents/pool/auto_assign/scan.rs index f7821efb..277466ae 100644 --- a/server/src/agents/pool/auto_assign/scan.rs +++ b/server/src/agents/pool/auto_assign/scan.rs @@ -168,6 +168,7 @@ mod tests { "9970_story_archived", "6_archived", "---\nname: Archived\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Archived\n---\n"), ); // Also place a stale .md file in a temp 1_backlog/ dir. @@ -200,9 +201,24 @@ mod tests { fn scan_stage_items_returns_sorted_story_ids() { // Write items via the CRDT store (the primary source of truth). crate::db::ensure_content_store(); - crate::db::write_item_with_content("9942_story_foo", "2_current", "---\nname: foo\n---"); - crate::db::write_item_with_content("9940_story_bar", "2_current", "---\nname: bar\n---"); - crate::db::write_item_with_content("9935_story_baz", "2_current", "---\nname: baz\n---"); + crate::db::write_item_with_content( + "9942_story_foo", + "2_current", + "---\nname: foo\n---", + crate::db::ItemMeta::from_yaml("---\nname: foo\n---"), + ); + crate::db::write_item_with_content( + "9940_story_bar", + "2_current", + "---\nname: bar\n---", + crate::db::ItemMeta::from_yaml("---\nname: bar\n---"), + ); + crate::db::write_item_with_content( + "9935_story_baz", + "2_current", + "---\nname: baz\n---", + crate::db::ItemMeta::from_yaml("---\nname: baz\n---"), + ); let tmp = tempfile::tempdir().unwrap(); let items = scan_stage_items(tmp.path(), "2_current"); diff --git a/server/src/agents/pool/auto_assign/story_checks.rs b/server/src/agents/pool/auto_assign/story_checks.rs index b72d9ea0..8e1ca976 100644 --- a/server/src/agents/pool/auto_assign/story_checks.rs +++ b/server/src/agents/pool/auto_assign/story_checks.rs @@ -188,6 +188,9 @@ mod tests { "10_spike_research", "3_qa", "---\nname: Research spike\nreview_hold: true\n---\n# Spike\n", + crate::db::ItemMeta::from_yaml( + "---\nname: Research spike\nreview_hold: true\n---\n# Spike\n", + ), ); assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research")); } diff --git a/server/src/agents/pool/pipeline/advance/helpers.rs b/server/src/agents/pool/pipeline/advance/helpers.rs index e0c0933b..3dee06fa 100644 --- a/server/src/agents/pool/pipeline/advance/helpers.rs +++ b/server/src/agents/pool/pipeline/advance/helpers.rs @@ -88,7 +88,12 @@ pub(super) fn write_review_hold_to_store(story_id: &str) { .flatten() .map(|i| i.stage.dir_name().to_string()) .unwrap_or_else(|| "3_qa".to_string()); - crate::db::write_item_with_content(story_id, &stage, &updated); + crate::db::write_item_with_content( + story_id, + &stage, + &updated, + crate::db::ItemMeta::from_yaml(&updated), + ); } else { slog_error!("[pipeline] Cannot write review_hold for '{story_id}': no content in store"); } diff --git a/server/src/agents/pool/pipeline/advance/tests.rs b/server/src/agents/pool/pipeline/advance/tests.rs index b202522d..7cba7d40 100644 --- a/server/src/agents/pool/pipeline/advance/tests.rs +++ b/server/src/agents/pool/pipeline/advance/tests.rs @@ -172,7 +172,12 @@ async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() { // Seed story via CRDT (the only source of truth). crate::db::ensure_content_store(); - crate::db::write_item_with_content("173_story_test", "2_current", "---\nname: test\n---\n"); + crate::db::write_item_with_content( + "173_story_test", + "2_current", + "---\nname: test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: test\n---\n"), + ); // Write a project.toml with a qa agent so start_agent can resolve it. fs::create_dir_all(root.join(".huskies")).unwrap(); diff --git a/server/src/agents/pool/pipeline/advance/tests_regression.rs b/server/src/agents/pool/pipeline/advance/tests_regression.rs index 198e97b5..c757dd18 100644 --- a/server/src/agents/pool/pipeline/advance/tests_regression.rs +++ b/server/src/agents/pool/pipeline/advance/tests_regression.rs @@ -51,6 +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"), ); let pool = AgentPool::new_test(3001); @@ -145,11 +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::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"), ); let pool = AgentPool::new_test(3001); @@ -245,7 +248,12 @@ async fn stale_mergemaster_advance_for_done_story_is_noop() { let story_id = "9929_story_zombie_merge"; let content = "---\nname: Zombie Merge Test\n---\n"; crate::db::write_content(story_id, content); - crate::db::write_item_with_content(story_id, "5_done", content); + crate::db::write_item_with_content( + story_id, + "5_done", + content, + crate::db::ItemMeta::from_yaml(content), + ); let pool = AgentPool::new_test(3001); let mut rx = pool.watcher_tx.subscribe(); @@ -381,6 +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"), ); // Simulate a passing run_tests call during the agent's session (bug 668): @@ -474,6 +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"), ); // Write a project.toml with max_retries = 1. @@ -601,6 +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"), ); // Explicitly ensure no test evidence exists for this story. @@ -730,6 +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"), ); // Write the run_tests evidence — simulates the agent having called run_tests @@ -813,6 +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"), ); let pool = AgentPool::new_test(3001); diff --git a/server/src/agents/pool/pipeline/merge/tests.rs b/server/src/agents/pool/pipeline/merge/tests.rs index 07ab2a60..2039c13b 100644 --- a/server/src/agents/pool/pipeline/merge/tests.rs +++ b/server/src/agents/pool/pipeline/merge/tests.rs @@ -651,6 +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"), ); let pool = Arc::new(AgentPool::new_test(3001)); @@ -787,6 +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"), ); let pool = Arc::new(AgentPool::new_test(3001)); @@ -898,6 +900,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"), ); 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 946d6b24..b8746824 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -597,7 +597,12 @@ mod tests { crate::db::ensure_content_store(); let story_id = "9960_story_gate_injection_881"; - crate::db::write_item_with_content(story_id, "2_current", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + story_id, + "2_current", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); crate::crdt_state::set_retry_count(story_id, 1); let gate_output = @@ -630,7 +635,12 @@ mod tests { crate::db::ensure_content_store(); let story_id = "9961_story_no_gate_injection_881"; - crate::db::write_item_with_content(story_id, "2_current", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + story_id, + "2_current", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); // retry_count is 0 (default — never bumped). crate::db::write_content(&format!("{story_id}:gate_output"), "some previous output"); @@ -690,7 +700,12 @@ mod tests { crate::db::ensure_content_store(); let story_id = "9962_story_abort_respawn_882"; - crate::db::write_item_with_content(story_id, "2_current", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + story_id, + "2_current", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); let db_key = format!("{story_id}:abort_respawn_count"); const CAP: u32 = 5; diff --git a/server/src/agents/pool/start/tests_concurrency.rs b/server/src/agents/pool/start/tests_concurrency.rs index 9fe21bfb..7c4940b0 100644 --- a/server/src/agents/pool/start/tests_concurrency.rs +++ b/server/src/agents/pool/start/tests_concurrency.rs @@ -429,7 +429,12 @@ async fn start_agent_rejects_mergemaster_on_coding_stage_story() { ) .unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("310_story_foo", "2_current", "---\nname: Foo\n---\n"); + crate::db::write_item_with_content( + "310_story_foo", + "2_current", + "---\nname: Foo\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), + ); let pool = AgentPool::new_test(3099); let result = pool @@ -463,7 +468,12 @@ async fn start_agent_rejects_coder_on_qa_stage_story() { ) .unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("8842_story_qa_guard", "3_qa", "---\nname: QA Guard\n---\n"); + crate::db::write_item_with_content( + "8842_story_qa_guard", + "3_qa", + "---\nname: QA Guard\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: QA Guard\n---\n"), + ); let pool = AgentPool::new_test(3099); let result = pool @@ -497,7 +507,12 @@ async fn start_agent_rejects_qa_on_merge_stage_story() { ) .unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("55_story_baz", "4_merge", "---\nname: Baz\n---\n"); + crate::db::write_item_with_content( + "55_story_baz", + "4_merge", + "---\nname: Baz\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Baz\n---\n"), + ); let pool = AgentPool::new_test(3099); let result = pool diff --git a/server/src/agents/pool/stop.rs b/server/src/agents/pool/stop.rs index 62a2d105..91bc7e01 100644 --- a/server/src/agents/pool/stop.rs +++ b/server/src/agents/pool/stop.rs @@ -143,7 +143,12 @@ mod tests { let story_content = "test"; fs::write(current.join("60_story_cleanup.md"), story_content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("60_story_cleanup", "2_current", story_content); + crate::db::write_item_with_content( + "60_story_cleanup", + "2_current", + story_content, + crate::db::ItemMeta::from_yaml(story_content), + ); let pool = AgentPool::new_test(3001); pool.inject_test_agent("60_story_cleanup", "coder-1", AgentStatus::Completed); diff --git a/server/src/agents/pool/worktree.rs b/server/src/agents/pool/worktree.rs index 471468ae..07a88aa0 100644 --- a/server/src/agents/pool/worktree.rs +++ b/server/src/agents/pool/worktree.rs @@ -42,7 +42,12 @@ mod tests { #[test] fn find_active_story_stage_detects_current() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("10_story_test", "2_current", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + "10_story_test", + "2_current", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( find_active_story_stage(tmp.path(), "10_story_test"), @@ -53,7 +58,12 @@ mod tests { #[test] fn find_active_story_stage_detects_qa() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("11_story_test", "3_qa", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + "11_story_test", + "3_qa", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( find_active_story_stage(tmp.path(), "11_story_test"), @@ -64,7 +74,12 @@ mod tests { #[test] fn find_active_story_stage_detects_merge() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("12_story_test", "4_merge", "---\nname: Test\n---\n"); + crate::db::write_item_with_content( + "12_story_test", + "4_merge", + "---\nname: Test\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test\n---\n"), + ); let tmp = tempfile::tempdir().unwrap(); assert!(matches!( find_active_story_stage(tmp.path(), "12_story_test"), diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index 6dc672a7..0cbe034c 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -527,6 +527,9 @@ fn merge_item_with_failure_shows_stop_sign_and_snippet() { "903_story_merge_fail", "4_merge", "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", + crate::db::ItemMeta::from_yaml( + "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", + ), ); let items = vec![make_item( "903_story_merge_fail", @@ -552,7 +555,12 @@ fn merge_item_failure_snippet_truncated_at_120_chars() { crate::db::ensure_content_store(); let long_reason = "x".repeat(200); let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n"); - crate::db::write_item_with_content("904_story_long_fail", "4_merge", &content); + crate::db::write_item_with_content( + "904_story_long_fail", + "4_merge", + &content, + crate::db::ItemMeta::from_yaml(&content), + ); let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); @@ -584,11 +592,21 @@ fn merge_item_failure_snippet_is_first_non_empty_line() { "---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n", reason.replace('\n', "\\n") ); - crate::db::write_item_with_content("905_story_multiline", "4_merge", &content); + crate::db::write_item_with_content( + "905_story_multiline", + "4_merge", + &content, + crate::db::ItemMeta::from_yaml(&content), + ); // Write with literal \n as the content (simulating stored text with newlines). let content2 = "---\nname: Multi Line\nmerge_failure: |\n \n first line of error\n second line\n---\n"; - crate::db::write_item_with_content("905_story_multiline", "4_merge", content2); + crate::db::write_item_with_content( + "905_story_multiline", + "4_merge", + content2, + crate::db::ItemMeta::from_yaml(content2), + ); let items = vec![make_item( "905_story_multiline", "Multi Line", @@ -615,6 +633,9 @@ fn merge_item_det_merge_running_preferred_over_failure() { "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", + ), ); // 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/unblock.rs b/server/src/chat/commands/unblock.rs index 58839383..d43c7516 100644 --- a/server/src/chat/commands/unblock.rs +++ b/server/src/chat/commands/unblock.rs @@ -100,7 +100,12 @@ fn unblock_by_story_id(story_id: &str) -> String { .flatten() .map(|i| i.stage.dir_name().to_string()) .unwrap_or_else(|| "2_current".to_string()); - crate::db::write_item_with_content(story_id, &stage, &updated); + crate::db::write_item_with_content( + story_id, + &stage, + &updated, + crate::db::ItemMeta::from_yaml(&updated), + ); crate::crdt_state::set_retry_count(story_id, 0); } } diff --git a/server/src/chat/test_helpers.rs b/server/src/chat/test_helpers.rs index d2d559c6..938c212e 100644 --- a/server/src/chat/test_helpers.rs +++ b/server/src/chat/test_helpers.rs @@ -19,5 +19,6 @@ pub(crate) fn write_story_file(root: &Path, stage: &str, filename: &str, content let story_id = filename.trim_end_matches(".md"); crate::db::ensure_content_store(); - crate::db::write_item_with_content(story_id, stage, content); + let meta = crate::db::ItemMeta::from_yaml(content); + crate::db::write_item_with_content(story_id, stage, content, meta); } diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 2c2f0390..00b5a208 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -261,6 +261,9 @@ 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", + ), ); let tmp = tempfile::tempdir().unwrap(); @@ -291,6 +294,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"), ); 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 5e841d60..483c6232 100644 --- a/server/src/chat/transport/matrix/start.rs +++ b/server/src/chat/transport/matrix/start.rs @@ -275,6 +275,7 @@ mod tests { "9976_story_test", "1_backlog", "---\nname: Test Story\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Test Story\n---\n"), ); let agents = Arc::new(AgentPool::new_test(3000)); diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 518a5143..2e1a135b 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -21,7 +21,7 @@ pub mod ops; pub mod shadow_write; pub use content_store::{all_content_ids, delete_content, read_content, write_content}; -pub use ops::{delete_item, move_item_stage, next_item_number, write_item_with_content}; +pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content}; pub use shadow_write::init; #[cfg(test)] @@ -320,6 +320,65 @@ mod tests { ); } + /// Story 864: `write_item_with_content` no longer parses YAML front-matter + /// from `content`. The CRDT metadata reflects ONLY what the caller passes + /// via `ItemMeta`. This test writes a body without any front-matter at + /// all, sets metadata explicitly, and asserts the CRDT picks up the typed + /// values, not anything derived from `content`. + #[test] + fn write_item_typed_meta_takes_precedence_over_content() { + crate::crdt_state::init_for_test(); + ensure_content_store(); + let story_id = "9864_story_typed_meta"; + + // Body has NO YAML header — just plain markdown. + let content = "# Just a heading\n\nNo front matter here.\n"; + let meta = ItemMeta { + name: Some("Typed Name".into()), + agent: Some("coder-1".into()), + retry_count: Some(2), + blocked: Some(true), + depends_on: Some(vec![100, 200]), + }; + write_item_with_content(story_id, "2_current", content, meta); + + let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT"); + assert_eq!(view.stage, "2_current"); + assert_eq!(view.name.as_deref(), Some("Typed Name")); + assert_eq!(view.agent.as_deref(), Some("coder-1")); + assert_eq!(view.retry_count, Some(2)); + assert_eq!(view.blocked, Some(true)); + assert_eq!(view.depends_on, Some(vec![100, 200])); + + // Content is stored verbatim (no parsing, no rewrite). + assert_eq!(read_content(story_id).as_deref(), Some(content)); + } + + /// Story 864: passing `ItemMeta::default()` against a content blob that + /// LOOKS like front-matter must NOT silently extract metadata into the + /// CRDT. The whole point of removing the implicit YAML round-trip is + /// that metadata only flows in through the typed `ItemMeta` arg. + #[test] + fn write_item_default_meta_ignores_yaml_in_content() { + crate::crdt_state::init_for_test(); + ensure_content_store(); + let story_id = "9864_story_yaml_ignored"; + + let content = "---\nname: Should Not Appear\nagent: ghost\n---\n# Body\n"; + write_item_with_content(story_id, "2_current", content, ItemMeta::default()); + + let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT"); + assert_eq!(view.stage, "2_current"); + assert_eq!( + view.name, None, + "name must come from typed meta, not parsed YAML" + ); + assert_eq!( + view.agent, None, + "agent must come from typed meta, not parsed YAML" + ); + } + /// Bug 780: stage transitions must reset retry_count to 0 in the CRDT. /// Carryover from prior-stage retries was tripping the auto-assigner's /// deterministic-merge skip logic. diff --git a/server/src/db/ops.rs b/server/src/db/ops.rs index b765224e..fddeb9f4 100644 --- a/server/src/db/ops.rs +++ b/server/src/db/ops.rs @@ -9,23 +9,65 @@ use super::content_store::{ use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg}; use crate::io::story_metadata::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. +#[derive(Default, Clone, Debug)] +pub struct ItemMeta { + pub name: Option, + pub agent: Option, + pub retry_count: Option, + pub blocked: Option, + pub depends_on: Option>, +} + +impl ItemMeta { + /// Convenience constructor for the common "just set a name" case. + #[cfg(test)] + pub fn named(name: impl Into) -> Self { + Self { + name: Some(name.into()), + ..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). /// /// This is the primary write path for the DB-backed pipeline. It updates /// the CRDT, the in-memory content store, and the SQLite shadow table. -pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) { - let (name, agent, retry_count, blocked, depends_on) = match parse_front_matter(content) { - Ok(meta) => ( - meta.name, - meta.agent, - meta.retry_count.map(|r| r as i64), - meta.blocked, - meta.depends_on - .as_ref() - .and_then(|d| serde_json::to_string(d).ok()), - ), - Err(_) => (None, None, None, None, None), - }; +/// +/// The metadata in `meta` is authoritative: this function does NOT parse +/// `content` to extract front-matter fields. Callers must pass typed +/// metadata explicitly via `ItemMeta`. +pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta: ItemMeta) { + let depends_on_json = meta + .depends_on + .as_ref() + .and_then(|d| serde_json::to_string(d).ok()); // Update in-memory content store. ensure_content_store(); @@ -42,11 +84,11 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) { crate::crdt_state::write_item( story_id, stage, - name.as_deref(), - agent.as_deref(), - retry_count, - blocked, - depends_on.as_deref(), + meta.name.as_deref(), + meta.agent.as_deref(), + meta.retry_count, + meta.blocked, + depends_on_json.as_deref(), None, None, merged_at_ts, @@ -57,11 +99,11 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) { let msg = PipelineWriteMsg { story_id: story_id.to_string(), stage: stage.to_string(), - name, - agent, - retry_count, - blocked, - depends_on, + name: meta.name, + agent: meta.agent, + retry_count: meta.retry_count, + blocked: meta.blocked, + depends_on: depends_on_json, content: Some(content.to_string()), }; let _ = db.tx.send(msg); diff --git a/server/src/http/agents/tests.rs b/server/src/http/agents/tests.rs index 30e0d0ca..fdacfc6e 100644 --- a/server/src/http/agents/tests.rs +++ b/server/src/http/agents/tests.rs @@ -326,6 +326,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."), ); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx) }; @@ -348,6 +349,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."), ); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx) }; diff --git a/server/src/http/mcp/diagnostics/permission.rs b/server/src/http/mcp/diagnostics/permission.rs index 9443c890..a9aad1b8 100644 --- a/server/src/http/mcp/diagnostics/permission.rs +++ b/server/src/http/mcp/diagnostics/permission.rs @@ -449,7 +449,12 @@ mod tests { let content = "---\nname: Test\n---\n"; fs::write(backlog.join("5_story_test.md"), content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("5_story_test", "1_backlog", content); + crate::db::write_item_with_content( + "5_story_test", + "1_backlog", + content, + crate::db::ItemMeta::from_yaml(content), + ); let ctx = test_ctx(root); let result = super::super::tool_move_story( @@ -476,7 +481,12 @@ mod tests { let content = "---\nname: Back\n---\n"; fs::write(current.join("6_story_back.md"), content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("6_story_back", "2_current", content); + crate::db::write_item_with_content( + "6_story_back", + "2_current", + content, + crate::db::ItemMeta::from_yaml(content), + ); let ctx = test_ctx(root); let result = super::super::tool_move_story( @@ -503,7 +513,12 @@ mod tests { let content = "---\nname: Idem\n---\n"; fs::write(current.join("9907_story_idem.md"), content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("9907_story_idem", "2_current", content); + crate::db::write_item_with_content( + "9907_story_idem", + "2_current", + content, + crate::db::ItemMeta::from_yaml(content), + ); let ctx = test_ctx(root); let result = super::super::tool_move_story( diff --git a/server/src/http/mcp/status_tools.rs b/server/src/http/mcp/status_tools.rs index be7cf19e..c55788ff 100644 --- a/server/src/http/mcp/status_tools.rs +++ b/server/src/http/mcp/status_tools.rs @@ -366,7 +366,12 @@ 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"; - crate::db::write_item_with_content("9887_story_blocked_test", "2_current", story_content); + crate::db::write_item_with_content( + "9887_story_blocked_test", + "2_current", + story_content, + crate::db::ItemMeta::from_yaml(story_content), + ); 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) @@ -388,7 +393,12 @@ mod tests { 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"; - crate::db::write_item_with_content("9886_story_status_test", "2_current", story_content); + crate::db::write_item_with_content( + "9886_story_status_test", + "2_current", + story_content, + crate::db::ItemMeta::from_yaml(story_content), + ); 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 7534495b..cc5f1c68 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -290,11 +290,17 @@ mod tests { "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", + ), ); 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", + ), ); let ctx = test_ctx(tmp.path()); @@ -438,7 +444,12 @@ mod tests { let content = "# Bug 9901: Crash\n"; std::fs::write(&bug_file, content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("9901_bug_crash", "1_backlog", content); + crate::db::write_item_with_content( + "9901_bug_crash", + "1_backlog", + content, + crate::db::ItemMeta::from_yaml(content), + ); // Stage the file so it's tracked std::process::Command::new("git") .args(["add", "."]) diff --git a/server/src/http/mcp/story_tools/criteria.rs b/server/src/http/mcp/story_tools/criteria.rs index 2a35e70b..74a163be 100644 --- a/server/src/http/mcp/story_tools/criteria.rs +++ b/server/src/http/mcp/story_tools/criteria.rs @@ -421,6 +421,9 @@ 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", + ), ); let ctx = test_ctx(tmp.path()); @@ -514,6 +517,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"), ); let ctx = test_ctx(tmp.path()); @@ -547,7 +551,12 @@ mod tests { // Write story content to CRDT with a pre-populated Test Results section let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n\n"; crate::db::ensure_content_store(); - crate::db::write_item_with_content("9905_story_file_only", "2_current", story_content); + crate::db::write_item_with_content( + "9905_story_file_only", + "2_current", + story_content, + crate::db::ItemMeta::from_yaml(story_content), + ); let ctx = test_ctx(tmp.path()); @@ -618,6 +627,9 @@ 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", + ), ); let ctx = test_ctx(tmp.path()); @@ -672,6 +684,9 @@ 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", + ), ); let ctx = test_ctx(tmp.path()); @@ -711,6 +726,9 @@ 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", + ), ); let ctx = test_ctx(tmp.path()); @@ -732,6 +750,9 @@ 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", + ), ); let ctx = test_ctx(tmp.path()); diff --git a/server/src/http/mcp/story_tools/epic.rs b/server/src/http/mcp/story_tools/epic.rs index c2b046fc..e6341cbc 100644 --- a/server/src/http/mcp/story_tools/epic.rs +++ b/server/src/http/mcp/story_tools/epic.rs @@ -246,17 +246,26 @@ mod tests { "9990_epic_rollup", "1_backlog", "---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n", + crate::db::ItemMeta::from_yaml( + "---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n", + ), ); // Write two member items: one done, one current. crate::db::write_item_with_content( "9991_story_member_done", "5_done", "---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n", + crate::db::ItemMeta::from_yaml( + "---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n", + ), ); crate::db::write_item_with_content( "9992_story_member_current", "2_current", "---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n", + crate::db::ItemMeta::from_yaml( + "---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n", + ), ); let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/http/mcp/story_tools/story/delete.rs b/server/src/http/mcp/story_tools/story/delete.rs index 456d6245..edfc0200 100644 --- a/server/src/http/mcp/story_tools/story/delete.rs +++ b/server/src/http/mcp/story_tools/story/delete.rs @@ -216,7 +216,12 @@ mod tests { let content = "---\nname: No Branch\n---\n"; std::fs::write(current_dir.join("51_story_no_branch.md"), content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("51_story_no_branch", "2_current", content); + crate::db::write_item_with_content( + "51_story_no_branch", + "2_current", + content, + crate::db::ItemMeta::from_yaml(content), + ); let ctx = test_ctx(tmp.path()); let result = tool_accept_story(&json!({"story_id": "51_story_no_branch"}), &ctx); diff --git a/server/src/http/mcp/story_tools/story/freeze.rs b/server/src/http/mcp/story_tools/story/freeze.rs index 3ee46efb..29cb9acc 100644 --- a/server/src/http/mcp/story_tools/story/freeze.rs +++ b/server/src/http/mcp/story_tools/story/freeze.rs @@ -60,6 +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"), ); let tmp = tempfile::tempdir().unwrap(); @@ -88,6 +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"), ); 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 d4980c31..bab13788 100644 --- a/server/src/http/mcp/story_tools/story/query.rs +++ b/server/src/http/mcp/story_tools/story/query.rs @@ -150,7 +150,12 @@ mod tests { ("4_merge", "9940_story_merge", "Merge Story"), ("5_done", "9950_story_done", "Done Story"), ] { - crate::db::write_item_with_content(id, stage, &format!("---\nname: \"{name}\"\n---\n")); + crate::db::write_item_with_content( + id, + stage, + &format!("---\nname: \"{name}\"\n---\n"), + crate::db::ItemMeta::from_yaml(&format!("---\nname: \"{name}\"\n---\n")), + ); } let ctx = test_ctx(tmp.path()); @@ -187,6 +192,7 @@ mod tests { "9921_story_active", "2_current", "---\nname: \"Active Story\"\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: \"Active Story\"\n---\n"), ); let ctx = test_ctx(tmp.path()); @@ -219,6 +225,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"), ); let ctx = test_ctx(tmp.path()); @@ -236,7 +243,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content("9908_test", "2_current", "## No front matter at all\n"); + crate::db::write_item_with_content( + "9908_test", + "2_current", + "## No front matter at all\n", + crate::db::ItemMeta::from_yaml("## No front matter at all\n"), + ); let ctx = test_ctx(tmp.path()); let result = tool_validate_stories(&ctx).unwrap(); diff --git a/server/src/http/mcp/story_tools/story/update.rs b/server/src/http/mcp/story_tools/story/update.rs index d25081ba..ce4172a6 100644 --- a/server/src/http/mcp/story_tools/story/update.rs +++ b/server/src/http/mcp/story_tools/story/update.rs @@ -150,7 +150,12 @@ mod tests { fs::create_dir_all(¤t).unwrap(); fs::write(current.join(format!("{story_id}.md")), content).unwrap(); crate::db::ensure_content_store(); - crate::db::write_item_with_content(story_id, "2_current", content); + crate::db::write_item_with_content( + story_id, + "2_current", + content, + crate::db::ItemMeta::from_yaml(content), + ); } #[test] diff --git a/server/src/http/workflow/bug_ops/tests.rs b/server/src/http/workflow/bug_ops/tests.rs index b565ef68..dd29b31e 100644 --- a/server/src/http/workflow/bug_ops/tests.rs +++ b/server/src/http/workflow/bug_ops/tests.rs @@ -46,8 +46,18 @@ fn next_item_number_increments_from_existing_bugs() { fs::write(backlog.join("1_bug_crash.md"), "").unwrap(); fs::write(backlog.join("3_bug_another.md"), "").unwrap(); // Also write to content store so next_item_number sees them. - crate::db::write_item_with_content("1_bug_crash", "1_backlog", "---\nname: Crash\n---\n"); - crate::db::write_item_with_content("3_bug_another", "1_backlog", "---\nname: Another\n---\n"); + crate::db::write_item_with_content( + "1_bug_crash", + "1_backlog", + "---\nname: Crash\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Crash\n---\n"), + ); + crate::db::write_item_with_content( + "3_bug_another", + "1_backlog", + "---\nname: Another\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Another\n---\n"), + ); assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4); } @@ -61,7 +71,12 @@ fn next_item_number_scans_archived_too() { fs::create_dir_all(&archived).unwrap(); fs::write(archived.join("5_bug_old.md"), "").unwrap(); // Also write to content store so next_item_number sees it. - crate::db::write_item_with_content("5_bug_old", "5_done", "---\nname: Old Bug\n---\n"); + crate::db::write_item_with_content( + "5_bug_old", + "5_done", + "---\nname: Old Bug\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Old Bug\n---\n"), + ); assert!(super::super::next_item_number(tmp.path()).unwrap() >= 6); } @@ -83,12 +98,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"), ); // 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"), ); let result = list_bug_files(tmp.path()).unwrap(); @@ -108,16 +125,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::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::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"), ); let result = list_bug_files(tmp.path()).unwrap(); @@ -349,6 +369,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"), ); let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index beef38a8..41a4a067 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -325,7 +325,12 @@ mod tests { ("4_merge", "9840_story_merge"), ("5_done", "9850_story_done"), ] { - crate::db::write_item_with_content(id, stage, &format!("---\nname: {id}\n---\n")); + crate::db::write_item_with_content( + id, + stage, + &format!("---\nname: {id}\n---\n"), + crate::db::ItemMeta::from_yaml(&format!("---\nname: {id}\n---\n")), + ); } let ctx = crate::http::context::AppContext::new_test(root); @@ -369,6 +374,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"), ); let ctx = crate::http::context::AppContext::new_test(root); @@ -404,6 +410,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"), ); let ctx = crate::http::context::AppContext::new_test(root); @@ -436,6 +443,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"), ); let ctx = crate::http::context::AppContext::new_test(root); @@ -466,11 +474,15 @@ mod tests { "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", + ), ); 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"), ); let tmp = tempfile::tempdir().unwrap(); @@ -499,11 +511,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::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"), ); let tmp = tempfile::tempdir().unwrap(); @@ -530,6 +544,7 @@ mod tests { "9872_story_example", "1_backlog", "---\nname: A Story\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: A Story\n---\n"), ); let tmp = tempfile::tempdir().unwrap(); @@ -545,11 +560,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::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"), ); let tmp = tempfile::tempdir().unwrap(); @@ -569,7 +586,12 @@ mod tests { #[test] fn validate_story_dirs_missing_front_matter() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("9875_story_no_fm", "2_current", "# No front matter\n"); + crate::db::write_item_with_content( + "9875_story_no_fm", + "2_current", + "# No front matter\n", + crate::db::ItemMeta::from_yaml("# No front matter\n"), + ); let tmp = tempfile::tempdir().unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); @@ -588,6 +610,7 @@ mod tests { "9876_story_no_name", "2_current", "---\n---\n# Story\n", + crate::db::ItemMeta::from_yaml("---\n---\n# Story\n"), ); 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 62866003..8967cfb0 100644 --- a/server/src/http/workflow/story_ops/create.rs +++ b/server/src/http/workflow/story_ops/create.rs @@ -143,6 +143,7 @@ mod tests { "36_story_existing", "1_backlog", "---\nname: Existing\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Existing\n---\n"), ); let number = super::super::super::next_item_number(tmp.path()).unwrap(); diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs index 408149df..b96c3307 100644 --- a/server/src/http/workflow/test_results.rs +++ b/server/src/http/workflow/test_results.rs @@ -175,6 +175,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"), ); let results = make_results(); @@ -200,6 +201,9 @@ 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", + ), ); let results = make_results(); @@ -222,6 +226,9 @@ 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", + ), ); let results = make_results(); @@ -241,6 +248,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"), ); let result = read_test_results_from_story_file(tmp.path(), "8004_story_empty"); @@ -262,6 +270,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"), ); let results = StoryTestResults { @@ -286,6 +295,7 @@ mod tests { "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(); diff --git a/server/src/http/workflow/utils.rs b/server/src/http/workflow/utils.rs index 2dd3f629..b6aa9fca 100644 --- a/server/src/http/workflow/utils.rs +++ b/server/src/http/workflow/utils.rs @@ -17,7 +17,12 @@ pub(crate) fn write_story_content( stage: &str, content: &str, ) { - crate::db::write_item_with_content(story_id, stage, content); + crate::db::write_item_with_content( + story_id, + stage, + content, + crate::db::ItemMeta::from_yaml(content), + ); } /// Determine what stage a story is in (from CRDT). @@ -262,7 +267,12 @@ mod tests { #[test] fn next_item_number_increments_beyond_existing() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("9877_story_foo", "1_backlog", "---\nname: Foo\n---\n"); + crate::db::write_item_with_content( + "9877_story_foo", + "1_backlog", + "---\nname: Foo\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Foo\n---\n"), + ); 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 fcaa4856..de6f86d7 100644 --- a/server/src/io/watcher/tests.rs +++ b/server/src/io/watcher/tests.rs @@ -77,7 +77,12 @@ fn stage_metadata_returns_correct_actions() { #[test] fn sweep_moves_old_items_to_archived() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("9880_story_sweep_old", "5_done", "---\nname: old\n---\n"); + crate::db::write_item_with_content( + "9880_story_sweep_old", + "5_done", + "---\nname: old\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: old\n---\n"), + ); // With ZERO retention, any Done item should be swept. sweep_done_to_archived(Duration::ZERO); @@ -96,7 +101,12 @@ fn sweep_moves_old_items_to_archived() { #[test] fn sweep_keeps_recent_items_in_done() { crate::db::ensure_content_store(); - crate::db::write_item_with_content("9881_story_sweep_new", "5_done", "---\nname: new\n---\n"); + crate::db::write_item_with_content( + "9881_story_sweep_new", + "5_done", + "---\nname: new\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: new\n---\n"), + ); // With a very long retention, the item (merged_at ≈ now) should stay. sweep_done_to_archived(Duration::from_secs(999_999)); @@ -118,6 +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"), ); // With ZERO retention, sweep should promote. diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 44b53170..c42039e9 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -618,7 +618,12 @@ fn regression_freeze_unfreeze_restores_crdt_stage() { let story_id = "9950_story_freeze_regression"; let content = "---\nname: Freeze Regression\n---\n# Story\n"; - crate::db::write_item_with_content(story_id, "2_current", content); + crate::db::write_item_with_content( + story_id, + "2_current", + content, + crate::db::ItemMeta::from_yaml(content), + ); // Confirm starting stage. let item = read_typed(story_id).unwrap().unwrap(); @@ -670,6 +675,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"), ); let reason = "Conflict in server/src/main.rs: both modified"; diff --git a/server/src/service/notifications/io/tests_notifications.rs b/server/src/service/notifications/io/tests_notifications.rs index eed38e8f..2fbb2263 100644 --- a/server/src/service/notifications/io/tests_notifications.rs +++ b/server/src/service/notifications/io/tests_notifications.rs @@ -18,6 +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"), ); let (watcher_tx, watcher_rx) = broadcast::channel::(16); @@ -144,6 +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"), ); 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 bff6c8a4..3380cc63 100644 --- a/server/src/service/notifications/io/tests_stage.rs +++ b/server/src/service/notifications/io/tests_stage.rs @@ -17,7 +17,12 @@ async fn stage_notification_uses_dynamic_room_ids() { let tmp = tempfile::tempdir().unwrap(); // Seed story via CRDT (the only source of truth). crate::db::ensure_content_store(); - crate::db::write_item_with_content("10_story_foo", "3_qa", "---\nname: Foo Story\n---\n"); + crate::db::write_item_with_content( + "10_story_foo", + "3_qa", + "---\nname: Foo Story\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Foo Story\n---\n"), + ); let (watcher_tx, watcher_rx) = broadcast::channel::(16); let (transport, calls) = MockTransport::new(); @@ -106,6 +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"), ); let tmp = tempfile::tempdir().unwrap(); @@ -128,6 +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"), ); let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/service/work_item/delete.rs b/server/src/service/work_item/delete.rs index 2486817b..5755fffb 100644 --- a/server/src/service/work_item/delete.rs +++ b/server/src/service/work_item/delete.rs @@ -214,6 +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"), ); let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/service/work_item/freeze.rs b/server/src/service/work_item/freeze.rs index 1970bed9..2c257cfd 100644 --- a/server/src/service/work_item/freeze.rs +++ b/server/src/service/work_item/freeze.rs @@ -79,6 +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"), ); let result = freeze(story_id); @@ -105,6 +106,7 @@ mod tests { story_id, "2_current", "---\nname: Already Frozen\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Already Frozen\n---\n"), ); freeze(story_id).expect("first freeze should succeed"); @@ -124,6 +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"), ); freeze(story_id).expect("freeze should succeed"); @@ -148,7 +151,12 @@ mod tests { fn unfreeze_not_frozen_item_returns_not_frozen() { crate::crdt_state::init_for_test(); let story_id = "8783_story_unfreeze_service_not_frozen"; - crate::db::write_item_with_content(story_id, "2_current", "---\nname: Not Frozen\n---\n"); + crate::db::write_item_with_content( + story_id, + "2_current", + "---\nname: Not Frozen\n---\n", + crate::db::ItemMeta::from_yaml("---\nname: Not Frozen\n---\n"), + ); let result = unfreeze(story_id); assert!( @@ -171,6 +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"), ); // Story B simulates the MCP tool path. @@ -179,6 +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"), ); // Both paths call service::work_item::freeze().