From 4888f051c37dc088400c34bcaf49e9c9b90d2835 Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 12 May 2026 20:13:17 +0100 Subject: [PATCH] =?UTF-8?q?wip(929):=20stage=2010=20sweep=20=E2=80=94=20pr?= =?UTF-8?q?oduction=20callsites=20move=20to=20CRDT,=20yaml=5Flegacy=20shri?= =?UTF-8?q?nks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 932 (review_hold register) and 933 (item_type + epic registers), the remaining production yaml_legacy callers all had typed CRDT equivalents. Migrated: - agents/lifecycle.rs: - transition_to_merge_failure writes to MergeJob.error CRDT entry instead of YAML body. The legacy `merge_failure: "..."` front-matter write is gone. - reject_story_from_qa inlines the QA-rejection notes append; no longer needs yaml_legacy::write_rejection_notes_to_content. - fields_to_clear_transform helper deleted along with all five callers — blocked/retry_count/merge_failure are typed CRDT fields now, so clearing the equivalent YAML keys is redundant. - http/workflow/pipeline.rs: - load_pipeline_state reads merge_failure from MergeJob.error (mirrors status_tools.rs). - validate_story_dirs checks the typed CRDT `name` register instead of parsing YAML front matter. - http/mcp/status_tools.rs: review_hold reads the typed CRDT register (yaml_residue wrap was the last one in this file). - http/mcp/story_tools/criteria.rs: story_name reads from CRDT. - service/agents/mod.rs::get_work_item_content: name/agent come from CRDT. - service/notifications/io/mod.rs::read_story_name: same. - http/workflow/bug_ops/{bug,refactor}.rs: name-fallback paths drop YAML parsing in favour of the CRDT-derived item.name. Dead helpers removed from db/yaml_legacy.rs: yaml_residue, write_merge_failure_in_content, write_rejection_notes_to_content, clear_front_matter_field_in_content, write_review_hold_in_content, clear_front_matter_field, write_review_hold (the last four shipped in 932). Remaining surface: FrontMatter / StoryMetadata structs, parse_front_matter, set_front_matter_field — kept for `coverage_baseline` writes via test_results.rs and the generic update_story front_matter escape hatch. Test fixtures rewritten to seed the CRDT register instead of relying on YAML parsing during write_item_with_content: - has_review_hold_returns_* tests - item_type_from_id_uses_crdt_register_for_numeric_ids - tool_list_epics_shows_member_rollup - get_work_item_content (both copies — http/agents + service/agents) - validate_story_dirs_missing_name_in_crdt - server_side_merge_*_sets_merge_failure (assert MergeJob.error, not YAML) cargo fmt --check, clippy --all-targets -- -D warnings, and the 2856-test suite all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/agents/lifecycle.rs | 81 +++++++------------ .../src/agents/pool/pipeline/merge/tests.rs | 18 +++-- server/src/db/yaml_legacy.rs | 65 --------------- server/src/http/agents/tests.rs | 27 +++++++ server/src/http/mcp/status_tools.rs | 9 +-- server/src/http/mcp/story_tools/bug.rs | 1 - server/src/http/mcp/story_tools/criteria.rs | 4 +- server/src/http/mcp/story_tools/refactor.rs | 1 - server/src/http/mcp/story_tools/spike.rs | 1 - server/src/http/workflow/bug_ops/bug.rs | 11 +-- server/src/http/workflow/bug_ops/refactor.rs | 13 +-- server/src/http/workflow/pipeline.rs | 66 ++++++--------- server/src/service/agents/mod.rs | 32 ++++++-- server/src/service/notifications/io/mod.rs | 9 +-- 14 files changed, 132 insertions(+), 206 deletions(-) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 7bb5f006..97d08647 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -10,7 +10,6 @@ use std::num::NonZeroU32; use std::path::Path; use std::process::Command; -use crate::db::yaml_legacy::clear_front_matter_field_in_content; use crate::pipeline_state::{ ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, TransitionFired, apply_transition, stage_label, @@ -46,23 +45,6 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { "story" } -type ContentTransform = Box String>; - -/// Build a content-transform closure that clears the given front-matter fields. -fn fields_to_clear_transform(fields: &[&str]) -> Option { - if fields.is_empty() { - return None; - } - let fields: Vec = fields.iter().map(|s| s.to_string()).collect(); - Some(Box::new(move |content: &str| { - let mut result = content.to_string(); - for field in &fields { - result = clear_front_matter_field_in_content(&result, field); - } - result - })) -} - /// Move a work item (story, bug, or spike) from `1_backlog` to `work/2_current/`. /// /// Only promotes from `1_backlog` — stories already in later stages (3_qa, 4_merge, @@ -142,8 +124,7 @@ pub fn move_story_to_done(story_id: &str) -> Result<(), String> { } }; - let transform = fields_to_clear_transform(&["merge_failure", "blocked"]); - apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref())) + apply_transition(story_id, event, None) .map(|_| ()) .map_err(|e| e.to_string()) } @@ -179,8 +160,7 @@ pub fn move_story_to_merge(story_id: &str) -> Result<(), String> { } }; - let transform = fields_to_clear_transform(&["blocked"]); - apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref())) + apply_transition(story_id, event, None) .map(|_| ()) .map_err(|e| e.to_string()) } @@ -197,14 +177,9 @@ pub fn move_story_to_qa(story_id: &str) -> Result<(), String> { return Ok(()); } - let transform = fields_to_clear_transform(&["blocked"]); - apply_transition( - story_id, - PipelineEvent::GatesStarted, - transform.as_ref().map(|f| f.as_ref()), - ) - .map(|_| ()) - .map_err(|e| e.to_string()) + apply_transition(story_id, PipelineEvent::GatesStarted, None) + .map(|_| ()) + .map_err(|e| e.to_string()) } /// Move a story from `work/3_qa/` back to `work/2_current/`, clearing @@ -225,7 +200,7 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { } else { let notes_owned = notes.to_string(); let transform = move |content: &str| -> String { - crate::db::yaml_legacy::write_rejection_notes_to_content(content, ¬es_owned) + format!("{content}\n\n## QA Rejection Notes\n\n{notes_owned}\n") }; apply_transition( story_id, @@ -245,13 +220,12 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { /// writes the resulting `Stage::Blocked` to the CRDT. Returns `Err` on /// `TransitionError` — callers must NOT fall back to direct register writes. pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> { - let transform = fields_to_clear_transform(&["blocked"]); apply_transition( story_id, PipelineEvent::Block { reason: reason.to_string(), }, - transform.as_ref().map(|f| f.as_ref()), + None, ) .map(|_| ()) .map_err(|e| e.to_string()) @@ -260,9 +234,10 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> /// Transition a story from `Stage::Merge` (or `Stage::MergeFailure`) to /// `Stage::MergeFailure` via the state machine. /// -/// Builds a `PipelineEvent::MergeFailed { reason }`, validates the transition, writes -/// the resulting `Stage::MergeFailure` to the CRDT, and persists the reason to front -/// matter so it survives server restarts. +/// Builds a `PipelineEvent::MergeFailed { reason }`, validates the transition, +/// writes the resulting `Stage::MergeFailure` to the CRDT, and persists the +/// reason to the typed `MergeJob.error` CRDT register so it survives server +/// restarts (story 929: the legacy YAML write of `merge_failure: "..."` is gone). /// /// When the story is already in `MergeFailure`, this is a silent self-loop: the /// returned `TransitionFired::before` will be `Stage::MergeFailure`. Callers @@ -273,18 +248,27 @@ pub fn transition_to_merge_failure( story_id: &str, reason: &str, ) -> Result { - let reason_owned = reason.to_string(); - let transform: Box String> = Box::new(move |content: &str| { - crate::db::yaml_legacy::write_merge_failure_in_content(content, &reason_owned) - }); - apply_transition( + let fired = apply_transition( story_id, PipelineEvent::MergeFailed { reason: reason.to_string(), }, - Some(&*transform), + None, ) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Persist the failure reason on the MergeJob CRDT entry so display tools + // (status_tools, chat status renderer, pipeline.rs::load_pipeline_state) + // can surface it without re-parsing YAML. + crate::crdt_state::write_merge_job( + story_id, + "failed", + chrono::Utc::now().timestamp() as f64, + None, + Some(reason), + ); + + Ok(fired) } /// Transition a story out of `Blocked` back to `Coding` via the state machine. @@ -294,14 +278,9 @@ pub fn transition_to_merge_failure( /// Returns `Err` on `TransitionError` — callers must NOT fall back to direct /// register writes. pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> { - let transform = fields_to_clear_transform(&["blocked", "merge_failure", "retry_count"]); - apply_transition( - story_id, - PipelineEvent::Unblock, - transform.as_ref().map(|f| f.as_ref()), - ) - .map(|_| ()) - .map_err(|e| e.to_string())?; + apply_transition(story_id, PipelineEvent::Unblock, None) + .map(|_| ()) + .map_err(|e| e.to_string())?; // Reset CRDT registers so the legacy `blocked`/`retry_count` fields match // the new typed stage. Pre-865, YAML stripping kept these in sync as a diff --git a/server/src/agents/pool/pipeline/merge/tests.rs b/server/src/agents/pool/pipeline/merge/tests.rs index 2039c13b..6f56b060 100644 --- a/server/src/agents/pool/pipeline/merge/tests.rs +++ b/server/src/agents/pool/pipeline/merge/tests.rs @@ -805,11 +805,12 @@ async fn server_side_merge_conflict_sets_merge_failure() { job.status ); - // merge_failure must be set in the content store. - let content = crate::db::read_content("757b_conflict").expect("story content must be in store"); + // Story 929: merge_failure detail is persisted on the MergeJob CRDT entry, + // not the YAML body. + let mj = crate::crdt_state::read_merge_job("757b_conflict").expect("merge job must be in CRDT"); assert!( - content.contains("merge_failure"), - "merge_failure must be written to story on conflict: {content}" + mj.error.is_some(), + "MergeJob.error must be set on conflict: {mj:?}" ); // Story must remain in 4_merge (not advanced to 5_done). @@ -924,11 +925,12 @@ async fn server_side_merge_gate_failure_sets_merge_failure() { MergeJobStatus::Running => panic!("should not still be running"), } - // merge_failure must be set in the content store. - let content = crate::db::read_content("757c_gates").expect("story content must be in store"); + // Story 929: merge_failure detail is persisted on the MergeJob CRDT + // entry, not the YAML body. + let mj = crate::crdt_state::read_merge_job("757c_gates").expect("merge job must be in CRDT"); assert!( - content.contains("merge_failure"), - "merge_failure must be written when gates fail: {content}" + mj.error.is_some(), + "MergeJob.error must be set on gate failure: {mj:?}" ); // Story must remain in 4_merge. diff --git a/server/src/db/yaml_legacy.rs b/server/src/db/yaml_legacy.rs index f1e0977a..f96715db 100644 --- a/server/src/db/yaml_legacy.rs +++ b/server/src/db/yaml_legacy.rs @@ -10,21 +10,6 @@ use crate::io::story_metadata::QaMode; use serde::Deserialize; -/// Identity wrapper that flags a yaml_legacy callsite blocked on adding a -/// CRDT register (story 929 residue). Every wrap is a grep-findable marker — -/// `grep -rn yaml_residue` enumerates every remaining gap — so it stays -/// visible in every code review. -/// -/// When the CRDT register lands and the caller is migrated, delete the wrap. -/// Once every wrap is gone, delete this function and `db::yaml_legacy` -/// entirely (929 stage 10). -/// -/// Filed sub-stories enumerate each gap: -/// - 933: epic mechanism — `item_type` and `epic` link fields. -pub fn yaml_residue(v: T) -> T { - v -} - /// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors /// the original `io::story_metadata::FrontMatter`. #[derive(Debug, Default, Deserialize)] @@ -155,47 +140,6 @@ pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &str) -> result } -/// Remove a `key: value` line from the YAML front matter of a markdown string. -pub(crate) fn clear_front_matter_field_in_content(contents: &str, key: &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}:"); - if let Some(idx) = lines[1..close_idx] - .iter() - .position(|l| l.trim_start().starts_with(&key_prefix)) - .map(|i| i + 1) - { - lines.remove(idx); - } else { - return contents.to_string(); - } - let mut result = lines.join("\n"); - if contents.ends_with('\n') { - result.push('\n'); - } - result -} - -/// Append rejection notes to a markdown body. -pub(crate) fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String { - format!("{contents}\n\n## QA Rejection Notes\n\n{notes}\n") -} - -/// Write or update `merge_failure` in story content. -pub(crate) fn write_merge_failure_in_content(contents: &str, reason: &str) -> String { - let escaped = reason - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', ""); - set_front_matter_field(contents, "merge_failure", &format!("\"{escaped}\"")) -} - #[cfg(test)] mod tests { use super::*; @@ -219,13 +163,4 @@ mod tests { let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1"); assert!(out.contains("agent: coder-1")); } - - #[test] - fn clear_front_matter_field_removes_key() { - let out = clear_front_matter_field_in_content( - "---\nname: X\nblocked: true\n---\n# B\n", - "blocked", - ); - assert!(!out.contains("blocked")); - } } diff --git a/server/src/http/agents/tests.rs b/server/src/http/agents/tests.rs index fdacfc6e..30fe4f7f 100644 --- a/server/src/http/agents/tests.rs +++ b/server/src/http/agents/tests.rs @@ -265,6 +265,7 @@ fn make_stage_dir(root: &path::Path, stage: &str) { #[tokio::test] async fn get_work_item_content_returns_content_from_backlog() { + crate::crdt_state::init_for_test(); let tmp = TempDir::new().unwrap(); let root = tmp.path(); make_stage_dir(root, "1_backlog"); @@ -273,6 +274,19 @@ async fn get_work_item_content_returns_content_from_backlog() { "---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.", ) .unwrap(); + // Story 929: name lives in the typed CRDT register, not in YAML on disk. + crate::crdt_state::write_item( + "42_story_foo", + "1_backlog", + Some("Foo Story"), + None, + None, + None, + None, + None, + None, + None, + ); let ctx = AppContext::new_test(root.to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api @@ -287,6 +301,7 @@ async fn get_work_item_content_returns_content_from_backlog() { #[tokio::test] async fn get_work_item_content_returns_content_from_current() { + crate::crdt_state::init_for_test(); let tmp = TempDir::new().unwrap(); let root = tmp.path(); make_stage_dir(root, "2_current"); @@ -295,6 +310,18 @@ async fn get_work_item_content_returns_content_from_current() { "---\nname: \"Bar Story\"\n---\n\nBar content.", ) .unwrap(); + crate::crdt_state::write_item( + "43_story_bar", + "2_current", + Some("Bar Story"), + None, + None, + None, + None, + None, + None, + None, + ); let ctx = AppContext::new_test(root.to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api diff --git a/server/src/http/mcp/status_tools.rs b/server/src/http/mcp/status_tools.rs index 359f7f8e..35d942db 100644 --- a/server/src/http/mcp/status_tools.rs +++ b/server/src/http/mcp/status_tools.rs @@ -216,11 +216,10 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result Result bool { false } -/// Extract bug name from content (heading or front matter). +/// Extract bug name from the `# Bug N: name` heading (last-resort fallback when +/// the CRDT name register is empty). #[allow(clippy::string_slice)] // colon_pos from find(": "); +2 skips ASCII ": " → valid boundary pub(super) fn extract_bug_name_from_content(content: &str) -> Option { - // Try front matter first. - if let Ok(meta) = parse_front_matter(content) - && let Some(name) = meta.name - { - return Some(name); - } - // Fallback: heading. for line in content.lines() { if let Some(rest) = line.strip_prefix("# Bug ") && let Some(colon_pos) = rest.find(": ") diff --git a/server/src/http/workflow/bug_ops/refactor.rs b/server/src/http/workflow/bug_ops/refactor.rs index e52b231e..3b623d65 100644 --- a/server/src/http/workflow/bug_ops/refactor.rs +++ b/server/src/http/workflow/bug_ops/refactor.rs @@ -1,6 +1,5 @@ //! Refactor-item creation and listing operations. -use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; use super::super::{next_item_number, slugify_name, write_story_content}; @@ -100,16 +99,10 @@ pub fn list_refactor_files(_root: &Path) -> Result, String } let sid = item.story_id.0; let name = if item.name.is_empty() { - None + sid.clone() } else { - Some(item.name) - } - .or_else(|| { - crate::db::read_content(&sid) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|m| m.name) - }) - .unwrap_or_else(|| sid.clone()); + item.name + }; refactors.push((sid, name)); } diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index f780c490..4a2101e3 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -1,7 +1,6 @@ //! Pipeline state — types and loading functions for the story pipeline. use crate::agents::AgentStatus; -use crate::db::yaml_legacy::parse_front_matter; use crate::http::context::AppContext; use serde::Serialize; use std::collections::HashMap; @@ -96,15 +95,13 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { let agent = agent_map.get(sid).cloned(); // Stories 929/932/933: review_hold, qa_mode, epic_id come from typed - // CRDT registers. merge_failure remains in YAML for now (tracked by - // 929 stage 10's sweep). + // CRDT registers. merge_failure detail lives on the MergeJob CRDT + // entry (same as status_tools.rs). let view = crate::crdt_state::read_item(sid); let review_hold = view.as_ref().map(|v| v.review_hold()).filter(|b| *b); let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string)); let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string)); - let merge_failure = crate::db::read_content(sid) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|meta| meta.merge_failure); + let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error); let story = UpcomingStory { story_id: sid.clone(), @@ -252,7 +249,10 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St Ok(stories) } -/// Validate story front matter for all backlog and current items. +/// Validate stories for all backlog and current items. +/// +/// Story 929: validation reads the typed CRDT `name` register; the legacy YAML +/// front-matter parse is gone. pub fn validate_story_dirs(_root: &Path) -> Result, String> { use crate::pipeline_state::Stage; @@ -260,44 +260,26 @@ pub fn validate_story_dirs(_root: &Path) -> Result, S let typed_items = crate::pipeline_state::read_all_typed(); for item in typed_items { - // Only validate backlog and current items (matching the old behaviour). if !matches!(item.stage, Stage::Backlog | Stage::Coding) { continue; } let story_id = item.story_id.0.clone(); + let name = crate::crdt_state::read_item(&story_id) + .and_then(|v| v.name().map(str::to_string)) + .filter(|s| !s.is_empty()); - match crate::db::read_content(&story_id) { - Some(contents) => match parse_front_matter(&contents) { - Ok(meta) => { - let mut errors = Vec::new(); - if meta.name.is_none() { - errors.push("Missing 'name' field".to_string()); - } - if errors.is_empty() { - results.push(StoryValidationResult { - story_id, - valid: true, - error: None, - }); - } else { - results.push(StoryValidationResult { - story_id, - valid: false, - error: Some(errors.join("; ")), - }); - } - } - Err(e) => results.push(StoryValidationResult { - story_id, - valid: false, - error: Some(e.to_string()), - }), - }, - None => results.push(StoryValidationResult { + if name.is_some() { + results.push(StoryValidationResult { + story_id, + valid: true, + error: None, + }); + } else { + results.push(StoryValidationResult { story_id, valid: false, - error: Some("No content found in content store".to_string()), - }), + error: Some("Missing 'name' field".to_string()), + }); } } @@ -581,13 +563,15 @@ mod tests { } #[test] - fn validate_story_dirs_missing_front_matter() { + fn validate_story_dirs_missing_name_in_crdt() { + crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); + // Item exists in CRDT but with no name register set. 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"), + crate::db::ItemMeta::default(), ); let tmp = tempfile::tempdir().unwrap(); @@ -597,7 +581,7 @@ mod tests { .find(|r| r.story_id == "9875_story_no_fm") .unwrap(); assert!(!r.valid); - assert_eq!(r.error.as_deref(), Some("Missing front matter")); + assert_eq!(r.error.as_deref(), Some("Missing 'name' field")); } #[test] diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index ec3e990a..1c041aef 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -183,14 +183,21 @@ pub fn get_work_item_content( let work_dir = project_root.join(".huskies").join("work"); let filename = format!("{story_id}.md"); + let crdt_view = crate::crdt_state::read_item(story_id); + let crdt_name = crdt_view + .as_ref() + .and_then(|v| v.name().map(str::to_string)); + let crdt_agent = crdt_view + .as_ref() + .and_then(|v| v.agent().map(str::to_string)); + for (stage_dir, stage_name) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { - let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage: stage_name.to_string(), - name: metadata.as_ref().and_then(|m| m.name.clone()), - agent: metadata.and_then(|m| m.agent), + name: crdt_name.clone(), + agent: crdt_agent.clone(), }); } } @@ -215,12 +222,11 @@ pub fn get_work_item_content( }) .unwrap_or("unknown") .to_string(); - let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage, - name: metadata.as_ref().and_then(|m| m.name.clone()), - agent: metadata.and_then(|m| m.agent), + name: crdt_name, + agent: crdt_agent, }); } @@ -329,6 +335,7 @@ max_budget_usd = 5.0 #[test] fn get_work_item_content_reads_from_backlog() { + crate::crdt_state::init_for_test(); let tmp = TempDir::new().unwrap(); make_stage_dirs(&tmp); write_story_file( @@ -336,6 +343,19 @@ max_budget_usd = 5.0 ".huskies/work/1_backlog/42_story_foo.md", "---\nname: \"Foo Story\"\n---\n\nSome content.", ); + // Story 929: name lives in the CRDT register. + crate::crdt_state::write_item( + "42_story_foo", + "1_backlog", + Some("Foo Story"), + None, + None, + None, + None, + None, + None, + None, + ); let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); assert_eq!(item.stage, "backlog"); diff --git a/server/src/service/notifications/io/mod.rs b/server/src/service/notifications/io/mod.rs index afd33abe..2700f5de 100644 --- a/server/src/service/notifications/io/mod.rs +++ b/server/src/service/notifications/io/mod.rs @@ -4,7 +4,6 @@ //! side effects: reading from the CRDT content store, loading configuration, //! and spawning the background listener task. -use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; mod listener; @@ -17,13 +16,11 @@ mod tests_notifications; #[cfg(test)] mod tests_stage; -/// Read the story name from the CRDT content store's YAML front matter. +/// Read the story name from the typed CRDT register (story 929). /// -/// Returns `None` if the item is not in the content store or has no parseable name. +/// Returns `None` if the item is not in the CRDT or has no name set. pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option { - let contents = crate::db::read_content(item_id)?; - let meta = parse_front_matter(&contents).ok()?; - meta.name + crate::crdt_state::read_item(item_id).and_then(|v| v.name().map(str::to_string)) } /// Look up a story name from the CRDT content store regardless of stage.